Files
TREK/server/tests/unit/nest/dawarich.client.test.ts
T
Maurice a6e8c66609 test(dawarich): the integration that shipped without a suite
Dawarich landed on dev untested, which is why release PR #2273 reads 79.0% on
SonarCloud's 80% new-code gate: 1352 of the release's 4610 uncovered units are
Dawarich's own. 946 cases across 30 files take the surface to 100% in shared,
99.7 in server and 99.6 in client, and the gate to roughly 85%.

What stays uncovered is thirteen type-narrowing guards and defensive fallbacks
the only caller cannot reach, each argued in the file beside it rather than
forced with a cast. No source, config or coverage threshold is touched.
2026-09-14 10:39:49 +02:00

605 lines
25 KiB
TypeScript

/**
* DawarichClient — what it puts on the wire, and what it makes of what comes
* back.
*
* The client is the only place TREK talks to a user's Dawarich instance, so
* both halves of that contract are pinned here: the URL and headers it builds
* (a pasted `/api` must not become `/api/api/v1`, and the API key must never
* reach a query string, where every proxy log between here and there would keep
* it), and the error code it hands back for each way a self-hosted instance can
* disappoint — a wrong key, a Cloudflare 403, a reverse proxy answering with an
* HTML login page.
*
* `safeFetch` and `readCappedJson` are mocked: the SSRF guard does a real DNS
* lookup and the capped reader wants a real stream, and neither is what this
* file is about. The readCappedJson stub still parses the bytes the response
* hands it, so "Dawarich answered HTML" stays a genuine parse failure rather
* than a mock returning `undefined` on command.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { safeFetchMock, readCappedJsonMock } = vi.hoisted(() => ({
safeFetchMock: vi.fn(),
readCappedJsonMock: vi.fn(),
}));
vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetch: safeFetchMock }));
vi.mock('../../../src/utils/cappedFetch', () => ({ readCappedJson: readCappedJsonMock }));
import {
DawarichClient,
DawarichError,
isoUtc,
unixSeconds,
POINTS_PAGE_SIZE,
TRACKS_PAGE_SIZE,
VISITS_PAGE_SIZE,
type DawarichCreds,
type DawarichVisitRaw,
} from '../../../src/nest/integrations/dawarich.client';
const API_KEY = 'sk-dawarich-2f7c9';
const CREDS: DawarichCreds = {
baseUrl: 'https://daw.example.org',
apiKey: API_KEY,
allowInsecureTls: false,
};
const FROM = new Date('2026-09-01T00:00:00.000Z');
const TO = new Date('2026-09-08T00:00:00.000Z');
/** The bits of a Response this client touches, plus the bytes the reader sees. */
interface Reply {
ok: boolean;
status: number;
headers: Headers;
text(): Promise<string>;
}
interface Recorded {
url: string;
init: RequestInit;
options?: { rejectUnauthorized?: boolean };
}
let calls: Recorded[] = [];
function reply(opts: {
status?: number;
body?: unknown;
/** Raw bytes, for the answers that are not JSON at all. */
raw?: string;
headers?: Record<string, string>;
}): Reply {
const status = opts.status ?? 200;
const raw = opts.raw ?? JSON.stringify(opts.body ?? {});
return {
ok: status >= 200 && status < 300,
status,
headers: new Headers(opts.headers ?? {}),
text: async () => raw,
};
}
/**
* Queue the answers, in order. The last one repeats, which is what the
* pagination cases want: twenty identical pages are one `reply`.
*/
function answerWith(...replies: Reply[]): void {
const queue = [...replies];
safeFetchMock.mockImplementation(
async (url: string, init: RequestInit, options?: { rejectUnauthorized?: boolean }) => {
calls.push({ url, init, options });
const next = queue.length > 1 ? queue.shift() : queue[0];
if (!next) throw new Error('the test queued no answer');
return next as unknown as Response;
},
);
}
/** A transport-level failure: DNS, TLS, the abort timeout. */
function failTransport(message: string): void {
safeFetchMock.mockImplementation(async (url: string, init: RequestInit) => {
calls.push({ url, init });
throw new Error(message);
});
}
/** The DawarichError a call rejects with, or a failure saying it did not. */
async function failure(run: () => Promise<unknown>): Promise<DawarichError> {
try {
await run();
} catch (err: unknown) {
if (err instanceof DawarichError) return err;
throw err;
}
throw new Error('expected the call to reject with a DawarichError');
}
function requestedUrl(index = 0): URL {
return new URL(calls[index].url);
}
function headerOf(index: number, name: string): string | undefined {
const headers = calls[index].init.headers as Record<string, string> | undefined;
return headers?.[name];
}
let client: DawarichClient;
beforeEach(() => {
calls = [];
client = new DawarichClient();
safeFetchMock.mockReset();
readCappedJsonMock.mockReset();
// The real reader, minus the stream: parse what arrived, answer undefined when
// it is not JSON — the same verdict the capped reader reaches.
readCappedJsonMock.mockImplementation(async (res: { text(): Promise<string> }) => {
const text = await res.text();
try {
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
});
// Nothing here may reach a real instance: an unstubbed call throws rather
// than silently resolving to undefined.
safeFetchMock.mockImplementation(async () => {
throw new Error('unstubbed safeFetch');
});
});
describe('DawarichClient — URL building', () => {
it('DAWARICH-CLIENT-001: appends /api/v1 to a bare origin', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe(CREDS);
expect(requestedUrl().href).toBe('https://daw.example.org/api/v1/users/me');
});
it('DAWARICH-CLIENT-002: tolerates a trailing slash', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/' });
expect(requestedUrl().href).toBe('https://daw.example.org/api/v1/users/me');
});
it('DAWARICH-CLIENT-003: tolerates a pasted /api instead of doubling it', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api' });
expect(requestedUrl().pathname).toBe('/api/v1/users/me');
});
it('DAWARICH-CLIENT-004: tolerates a pasted /api/v1, with or without the slash', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api/v1' });
await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api/v1/' });
expect(requestedUrl(0).href).toBe('https://daw.example.org/api/v1/users/me');
expect(requestedUrl(1).href).toBe('https://daw.example.org/api/v1/users/me');
});
it('DAWARICH-CLIENT-005: keeps a reverse-proxy sub-path and trims pasted whitespace', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe({ ...CREDS, baseUrl: ' https://home.example.org/dawarich/ ' });
expect(requestedUrl().href).toBe('https://home.example.org/dawarich/api/v1/users/me');
});
it('DAWARICH-CLIENT-006: keeps a non-default port', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe({ ...CREDS, baseUrl: 'http://192.168.178.9:3000/api' });
expect(requestedUrl().href).toBe('http://192.168.178.9:3000/api/v1/users/me');
});
it('DAWARICH-CLIENT-007: an undefined query value is dropped, not sent as the word "undefined"', async () => {
// Every method today builds its query out of String(), isoUtc() or
// unixSeconds(), so none of them can hand the helper an undefined; the
// helper's signature still accepts one, and the day a caller passes an
// optional filter through, `?per_page=undefined` is a request Dawarich
// would answer wrongly rather than reject. Reached through the private
// `get` because that is the only place the decision is made.
type PrivateGet = (
creds: DawarichCreds,
path: string,
query: Record<string, string | undefined>,
) => Promise<{ data: unknown; headers: Headers }>;
answerWith(reply({ body: { id: 1 } }));
const get = (client as unknown as { get: PrivateGet }).get.bind(client);
await get(CREDS, '/visits', { page: '2', per_page: undefined, order: 'asc' });
const url = requestedUrl();
expect(url.pathname).toBe('/api/v1/visits');
expect(url.searchParams.get('page')).toBe('2');
expect(url.searchParams.get('order')).toBe('asc');
expect(url.searchParams.has('per_page')).toBe(false);
expect(url.search).not.toContain('undefined');
});
});
describe('DawarichClient — authentication', () => {
it('DAWARICH-CLIENT-010: sends the key as a Bearer token and asks for JSON', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe(CREDS);
expect(headerOf(0, 'Authorization')).toBe(`Bearer ${API_KEY}`);
expect(headerOf(0, 'Accept')).toBe('application/json');
expect(calls[0].init.method).toBe('GET');
});
it('DAWARICH-CLIENT-011: never puts the key in the query string, on any endpoint', async () => {
answerWith(reply({ body: [] }));
await client.probe(CREDS);
await client.listVisits(CREDS, FROM, TO);
await client.listTracks(CREDS, FROM, TO);
await client.listPoints(CREDS, FROM, TO);
await client.listVisitedCities(CREDS, FROM, TO);
await client.findVisitsNear(CREDS, 54.09, 12.14, 120, 5);
expect(calls.length).toBeGreaterThanOrEqual(6);
for (const call of calls) {
const url = new URL(call.url);
expect(url.searchParams.get('api_key')).toBeNull();
expect(call.url).not.toContain(API_KEY);
expect(url.search).not.toMatch(/key|token|auth/i);
}
});
it('DAWARICH-CLIENT-012: maps allowInsecureTls onto rejectUnauthorized', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe(CREDS);
await client.probe({ ...CREDS, allowInsecureTls: true });
expect(calls[0].options).toEqual({ rejectUnauthorized: true });
expect(calls[1].options).toEqual({ rejectUnauthorized: false });
});
it('DAWARICH-CLIENT-013: bounds the request with an abort signal', async () => {
answerWith(reply({ body: { id: 1 } }));
await client.probe(CREDS);
expect(calls[0].init.signal).toBeInstanceOf(AbortSignal);
});
});
describe('DawarichClient — time formats', () => {
it('DAWARICH-CLIENT-020: isoUtc is UTC ISO-8601 without milliseconds', () => {
expect(isoUtc(new Date('2026-09-01T10:00:30.123Z'))).toBe('2026-09-01T10:00:30Z');
expect(isoUtc(new Date('2026-09-01T10:00:00.000Z'))).toBe('2026-09-01T10:00:00Z');
});
it('DAWARICH-CLIENT-021: unixSeconds is a bare digit string, floored', () => {
const value = unixSeconds(new Date('2026-09-01T10:00:30.900Z'));
expect(value).toMatch(/^\d+$/);
expect(new Date(Number(value) * 1000).toISOString()).toBe('2026-09-01T10:00:30.000Z');
});
it('DAWARICH-CLIENT-022: visits and tracks get ISO bounds, points and cities unix seconds', async () => {
answerWith(reply({ body: [] }));
await client.listVisits(CREDS, FROM, TO);
await client.listTracks(CREDS, FROM, TO);
await client.listPoints(CREDS, FROM, TO);
await client.listVisitedCities(CREDS, FROM, TO);
expect(requestedUrl(0).searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z');
expect(requestedUrl(1).searchParams.get('end_at')).toBe('2026-09-08T00:00:00Z');
expect(requestedUrl(2).searchParams.get('start_at')).toBe(unixSeconds(FROM));
expect(requestedUrl(2).searchParams.get('start_at')).toMatch(/^\d+$/);
expect(requestedUrl(3).searchParams.get('end_at')).toBe(unixSeconds(TO));
});
});
describe('DawarichClient — error classification', () => {
const cases: Array<{ id: string; status: number; code: string }> = [
{ id: 'DAWARICH-CLIENT-030', status: 401, code: 'unauthorized' },
{ id: 'DAWARICH-CLIENT-031', status: 403, code: 'forbidden' },
{ id: 'DAWARICH-CLIENT-032', status: 404, code: 'not_found' },
{ id: 'DAWARICH-CLIENT-033', status: 429, code: 'rate_limited' },
{ id: 'DAWARICH-CLIENT-034', status: 500, code: 'server_error' },
{ id: 'DAWARICH-CLIENT-035', status: 502, code: 'server_error' },
];
for (const { id, status, code } of cases) {
it(`${id}: HTTP ${status} is "${code}"`, async () => {
answerWith(reply({ status, raw: 'nope' }));
const err = await failure(() => client.probe(CREDS));
expect(err.code).toBe(code);
expect(err.status).toBe(status);
expect(err.name).toBe('DawarichError');
// The body of a failed answer is never parsed — it is as likely to be an
// HTML error page as JSON.
expect(readCappedJsonMock).not.toHaveBeenCalled();
});
}
it('DAWARICH-CLIENT-036: a transport failure is "unreachable" and keeps the reason as detail', async () => {
failTransport('getaddrinfo ENOTFOUND daw.example.org');
const err = await failure(() => client.probe(CREDS));
expect(err.code).toBe('unreachable');
expect(err.status).toBeUndefined();
expect(err.detail).toContain('ENOTFOUND');
});
it('DAWARICH-CLIENT-042: a rejection that is not an Error is still "unreachable", with no detail invented', async () => {
// undici rejects with an Error, but the SSRF guard sits in front of it and
// is free to reject with anything at all. Reaching for `.message` on
// whatever arrived is how the catch block itself starts throwing. The
// honest answer for a string is no detail rather than the string "undefined"
// shown to the user under "why the connection failed".
safeFetchMock.mockImplementation(() => Promise.reject('socket hang up'));
const err = await failure(() => client.probe(CREDS));
expect(err.code).toBe('unreachable');
expect(err.detail).toBeUndefined();
});
it('DAWARICH-CLIENT-037: an HTML login page is "invalid_response", not a parse crash', async () => {
answerWith(reply({ raw: '<!doctype html><title>Sign in</title>' }));
const err = await failure(() => client.probe(CREDS));
expect(err.code).toBe('invalid_response');
expect(err.message).toMatch(/not JSON/i);
});
it('DAWARICH-CLIENT-038: a body over the size cap is "invalid_response"', async () => {
answerWith(reply({ body: { huge: true } }));
readCappedJsonMock.mockResolvedValue(undefined);
const err = await failure(() => client.probe(CREDS));
expect(err.code).toBe('invalid_response');
// The cap is passed in, rather than the reader guessing one.
expect(readCappedJsonMock.mock.calls[0][1]).toBe(8 * 1024 * 1024);
});
it('DAWARICH-CLIENT-039: a 2xx answer is not classified as an error', async () => {
answerWith(reply({ status: 204, body: {} }));
await expect(client.probe(CREDS)).resolves.toEqual({ version: null });
});
});
describe('DawarichClient — probe', () => {
it('DAWARICH-CLIENT-040: asks /users/me and reads X-Dawarich-Version', async () => {
answerWith(reply({ body: { id: 7 }, headers: { 'X-Dawarich-Version': '1.14.4' } }));
const probe = await client.probe(CREDS);
expect(probe).toEqual({ version: '1.14.4' });
// /health skips authentication upstream, so it would prove nothing.
expect(requestedUrl().pathname).toBe('/api/v1/users/me');
expect(requestedUrl().pathname).not.toContain('health');
expect(requestedUrl().search).toBe('');
});
it('DAWARICH-CLIENT-041: an instance too old to send the banner probes as version null', async () => {
answerWith(reply({ body: { id: 7 } }));
await expect(client.probe(CREDS)).resolves.toEqual({ version: null });
});
});
describe('DawarichClient — listVisits', () => {
function visit(id: number): DawarichVisitRaw {
return {
id,
area_id: null,
started_at: '2026-09-02T08:00:00Z',
ended_at: '2026-09-02T09:30:00Z',
duration: 5400,
name: `Stop ${id}`,
status: 'confirmed',
confidence: 0.9,
confidence_band: 'high',
place: { latitude: '54.0879', longitude: '12.1408', id: id * 10 },
};
}
it('DAWARICH-CLIENT-050: sends the window, the page and the upstream page size', async () => {
answerWith(reply({ body: [visit(1)] }));
const out = await client.listVisits(CREDS, FROM, TO);
const url = requestedUrl();
expect(url.pathname).toBe('/api/v1/visits');
expect(url.searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z');
expect(url.searchParams.get('end_at')).toBe('2026-09-08T00:00:00Z');
expect(url.searchParams.get('page')).toBe('1');
expect(url.searchParams.get('per_page')).toBe(String(VISITS_PAGE_SIZE));
expect(out.visits).toHaveLength(1);
expect(out.truncated).toBe(false);
});
it('DAWARICH-CLIENT-051: walks every page X-Total-Pages announces and concatenates them', async () => {
answerWith(
reply({ body: [visit(1)], headers: { 'X-Total-Pages': '3', 'X-Dawarich-Version': '1.14.4' } }),
reply({ body: [visit(2)], headers: { 'X-Total-Pages': '3', 'X-Dawarich-Version': '9.9.9' } }),
reply({ body: [visit(3)], headers: { 'X-Total-Pages': '3' } }),
);
const out = await client.listVisits(CREDS, FROM, TO);
expect(calls).toHaveLength(3);
expect(out.visits.map(v => v.id)).toEqual([1, 2, 3]);
expect(out.truncated).toBe(false);
// The first page's banner is the answer; a later page cannot overwrite it.
expect(out.version).toBe('1.14.4');
expect(calls.map(c => new URL(c.url).searchParams.get('page'))).toEqual(['1', '2', '3']);
});
it('DAWARICH-CLIENT-052: an absent X-Total-Pages means one page', async () => {
answerWith(reply({ body: [visit(1)] }));
const out = await client.listVisits(CREDS, FROM, TO);
expect(calls).toHaveLength(1);
expect(out.visits).toHaveLength(1);
expect(out.version).toBeNull();
});
it('DAWARICH-CLIENT-053: stops at the page ceiling and says the result is truncated', async () => {
answerWith(reply({ body: [visit(1)], headers: { 'X-Total-Pages': '25' } }));
const out = await client.listVisits(CREDS, FROM, TO);
expect(calls).toHaveLength(20);
expect(out.visits).toHaveLength(20);
expect(out.truncated).toBe(true);
expect(new URL(calls[19].url).searchParams.get('page')).toBe('20');
});
it('DAWARICH-CLIENT-055: an X-Total-Pages that is not a number means one page, not twenty', async () => {
// A reverse proxy that rewrites or truncates the header must not turn one
// window into twenty identical requests against somebody's home server.
answerWith(reply({ body: [visit(1)], headers: { 'X-Total-Pages': 'many' } }));
const out = await client.listVisits(CREDS, FROM, TO);
expect(calls).toHaveLength(1);
expect(out.visits).toHaveLength(1);
expect(out.truncated).toBe(false);
});
it('DAWARICH-CLIENT-054: a payload that is not a list is "invalid_response"', async () => {
answerWith(reply({ body: { visits: [] } }));
const err = await failure(() => client.listVisits(CREDS, FROM, TO));
expect(err.code).toBe('invalid_response');
expect(err.message).toMatch(/not a list/i);
});
});
describe('DawarichClient — listTracks', () => {
const feature = (id: number) => ({
type: 'Feature',
geometry: { type: 'LineString', coordinates: [[12.1, 54.0], [12.2, 54.1]] },
properties: { id, start_at: '2026-09-02T08:00:00Z', end_at: '2026-09-02T09:00:00Z' },
});
it('DAWARICH-CLIENT-060: reads the GeoJSON features and asks for the smaller page size', async () => {
answerWith(reply({ body: { type: 'FeatureCollection', features: [feature(1), feature(2)] } }));
const out = await client.listTracks(CREDS, FROM, TO);
const url = requestedUrl();
expect(url.pathname).toBe('/api/v1/tracks');
expect(url.searchParams.get('per_page')).toBe(String(TRACKS_PAGE_SIZE));
expect(url.searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z');
expect(out.features.map(f => f.properties?.id)).toEqual([1, 2]);
expect(out.truncated).toBe(false);
});
it('DAWARICH-CLIENT-061: an instance with no tracks yet answers empty rather than throwing', async () => {
answerWith(reply({ body: { type: 'FeatureCollection' } }));
await expect(client.listTracks(CREDS, FROM, TO)).resolves.toEqual({ features: [], truncated: false });
calls = [];
answerWith(reply({ body: { features: 'nope' } }));
await expect(client.listTracks(CREDS, FROM, TO)).resolves.toEqual({ features: [], truncated: false });
});
it('DAWARICH-CLIENT-062: paginates over X-Total-Pages and truncates at the ceiling', async () => {
answerWith(
reply({ body: { features: [feature(1)] }, headers: { 'X-Total-Pages': '2' } }),
reply({ body: { features: [feature(2)] }, headers: { 'X-Total-Pages': '2' } }),
);
const paged = await client.listTracks(CREDS, FROM, TO);
expect(calls).toHaveLength(2);
expect(paged.features).toHaveLength(2);
expect(paged.truncated).toBe(false);
calls = [];
answerWith(reply({ body: { features: [feature(9)] }, headers: { 'X-Total-Pages': '99' } }));
const capped = await client.listTracks(CREDS, FROM, TO);
expect(calls).toHaveLength(20);
expect(capped.truncated).toBe(true);
});
it('DAWARICH-CLIENT-063: an unreadable X-Total-Pages means one page', async () => {
answerWith(reply({ body: { features: [feature(1)] }, headers: { 'X-Total-Pages': 'unknown' } }));
const out = await client.listTracks(CREDS, FROM, TO);
expect(calls).toHaveLength(1);
expect(out).toEqual({ features: [feature(1)], truncated: false });
});
});
describe('DawarichClient — listPoints', () => {
const point = (id: number) => ({ id, latitude: '54.0879', longitude: '12.1408', timestamp: 1788000000 + id });
it('DAWARICH-CLIENT-070: asks for slim points in ascending order over a unix-second window', async () => {
answerWith(reply({ body: [point(1)] }));
const out = await client.listPoints(CREDS, FROM, TO);
const url = requestedUrl();
expect(url.pathname).toBe('/api/v1/points');
expect(url.searchParams.get('slim')).toBe('true');
expect(url.searchParams.get('order')).toBe('asc');
expect(url.searchParams.get('per_page')).toBe(String(POINTS_PAGE_SIZE));
expect(url.searchParams.get('start_at')).toBe(unixSeconds(FROM));
expect(url.searchParams.get('end_at')).toBe(unixSeconds(TO));
expect(out).toEqual({ points: [point(1)], truncated: false });
});
it('DAWARICH-CLIENT-071: paginates over X-Total-Pages and truncates at the ceiling', async () => {
answerWith(
reply({ body: [point(1)], headers: { 'X-Total-Pages': '2' } }),
reply({ body: [point(2)], headers: { 'X-Total-Pages': '2' } }),
);
const paged = await client.listPoints(CREDS, FROM, TO);
expect(calls).toHaveLength(2);
expect(paged.points.map(p => p.id)).toEqual([1, 2]);
expect(paged.truncated).toBe(false);
calls = [];
answerWith(reply({ body: [point(3)], headers: { 'X-Total-Pages': '40' } }));
const capped = await client.listPoints(CREDS, FROM, TO);
expect(calls).toHaveLength(20);
expect(capped.points).toHaveLength(20);
expect(capped.truncated).toBe(true);
});
it('DAWARICH-CLIENT-073: an unreadable X-Total-Pages means one page', async () => {
answerWith(reply({ body: [point(1)], headers: { 'X-Total-Pages': 'lots' } }));
const out = await client.listPoints(CREDS, FROM, TO);
expect(calls).toHaveLength(1);
expect(out).toEqual({ points: [point(1)], truncated: false });
});
it('DAWARICH-CLIENT-072: a payload that is not a list is "invalid_response"', async () => {
answerWith(reply({ body: { points: [] } }));
const err = await failure(() => client.listPoints(CREDS, FROM, TO));
expect(err.code).toBe('invalid_response');
expect(err.message).toMatch(/not a list/i);
});
});
describe('DawarichClient — listVisitedCities', () => {
it('DAWARICH-CLIENT-080: unwraps the data envelope over a unix-second window', async () => {
const countries = [{ country: 'Germany', cities: [{ city: 'Rostock', points: 42, timestamp: 1788000000 }] }];
answerWith(reply({ body: { data: countries } }));
const out = await client.listVisitedCities(CREDS, FROM, TO);
expect(requestedUrl().pathname).toBe('/api/v1/countries/visited_cities');
expect(requestedUrl().searchParams.get('start_at')).toBe(unixSeconds(FROM));
expect(out).toEqual(countries);
});
it('DAWARICH-CLIENT-081: an envelope without a list answers empty', async () => {
answerWith(reply({ body: {} }));
await expect(client.listVisitedCities(CREDS, FROM, TO)).resolves.toEqual([]);
calls = [];
answerWith(reply({ body: { data: { Germany: [] } } }));
await expect(client.listVisitedCities(CREDS, FROM, TO)).resolves.toEqual([]);
});
});
describe('DawarichClient — findVisitsNear', () => {
it('DAWARICH-CLIENT-090: pins the radius explicitly instead of letting Dawarich guess 500 m', async () => {
const visits = [{ timestamp: 1788000000, distance_meters: 40, points_count: 12 }];
answerWith(reply({ body: { locations: [{ visits }] } }));
const out = await client.findVisitsNear(CREDS, 54.0879, 12.1408, 119.6, 5);
const url = requestedUrl();
expect(url.pathname).toBe('/api/v1/locations');
expect(url.searchParams.get('lat')).toBe('54.0879');
expect(url.searchParams.get('lon')).toBe('12.1408');
expect(url.searchParams.get('radius_override')).toBe('120');
expect(url.searchParams.get('limit')).toBe('5');
expect(out).toEqual(visits);
});
it('DAWARICH-CLIENT-091: a coordinate the recordings never saw answers empty', async () => {
answerWith(reply({ body: { locations: [] } }));
await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]);
calls = [];
answerWith(reply({ body: {} }));
await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]);
calls = [];
answerWith(reply({ body: { locations: [{}] } }));
await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]);
});
});