Files
TREK/server/tests/unit/nest/dawarich-tracks.service.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

719 lines
30 KiB
TypeScript

/**
* DawarichTracksService unit tests, DAWARICH-TRACKS-001 through
* DAWARICH-TRACKS-053. The service is built with `new` and every collaborator
* is a plain mock: no Nest container, no database, no socket. The helpers it
* delegates the actual geometry to (`bucketPointsByDay`, `bucketTracksByDay`)
* are the real ones, because what this service decides is *which* of them runs
* and with which window, and stubbing them would assert nothing about that.
*
* Four things here are worth pinning, because losing any of them is invisible
* until somebody's map is quietly wrong:
*
* - **the early returns.** A trip the caller cannot read, a trip that does not
* exist, a trip with no dates, a user with no connection and a nonsense
* window all leave without touching Dawarich. Two of them return `null`
* (which the controller turns into a 404) and the rest return an *empty
* track* (which the map draws as "nothing recorded"), and the difference
* between those is the difference between "no such trip" and "you were not
* recording". A regression that swapped them would 404 a perfectly good trip;
* - **the tracks-then-points fallback.** `/tracks` is a background-job product,
* so a fresh import has points and no tracks, and an instance old enough has
* no `/tracks` at all. The `catch` around the tracks call is the whole reason
* the overlay works on both, but it must not grow to cover the points call
* as well, or a genuinely broken connection would report "nothing recorded"
* instead of an error nobody can act on;
* - **the cache key.** User, window and UTC offset all belong in it. Drop the
* offset and two readers either side of a timezone change see each other's
* day boundaries; drop the user and they see each other's *routes*, which is
* a leak rather than a glitch. What must NOT be in it is the wording: the key
* is built from the resolved instants, so the journal writing local time and
* the planner writing `Z` share one entry for one window. The TTL boundary
* and the 64-entry eviction are pinned for the same reason those constants
* exist: a day of geometry per entry adds up fast;
* - **the offset reaching the bucketing.** Points carry a Unix timestamp and no
* zone, so "which day was that" is answered by the offset the caller sent. A
* dropped argument silently regroups everybody onto UTC days, and the days
* either side of a midnight are stitched together rather than left with a gap,
* which is why `pointCount` reports what was drawn and not what the archive
* holds.
*
* Every timestamp is written with an explicit offset (`Z` or `+HH:MM`) so the
* cases mean the same thing on a CI runner in UTC and on a laptop in Berlin,
* and the clock is frozen for the whole file so `fetchedAt` is a value a test
* can name rather than merely "some string".
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service';
import { DawarichError } from '../../../src/nest/integrations/dawarich.client';
import type {
DawarichClient,
DawarichCreds,
DawarichSlimPoint,
DawarichTrackFeature,
} from '../../../src/nest/integrations/dawarich.client';
import type { DawarichService } from '../../../src/nest/integrations/dawarich.service';
import type { DatabaseService } from '../../../src/nest/database/database.service';
import type { DawarichCapabilities } from '@trek/shared';
// ── Fixtures ─────────────────────────────────────────────────────────────────
/** Frozen so `fetchedAt` is assertable. */
const NOW_ISO = '2026-05-10T08:30:00.000Z';
const USER = 7;
const CREDS: DawarichCreds = {
baseUrl: 'https://dawarich.example',
apiKey: 'secret-key',
allowInsecureTls: false,
};
/** What `canAccessTrip` hands back for a trip the caller owns. */
const TRIP_ACCESS = { id: 5, user_id: USER, currency: 'EUR' };
const CAPABILITIES: DawarichCapabilities = {
visits: true,
tracks: true,
points: true,
locations: false,
visitedCities: true,
visitUpdatedAt: false,
visitCountryCode: false,
serverVersion: '1.14.5',
probedAt: '2026-05-09T00:00:00.000Z',
};
/** The answer every "there is nothing to ask about" path shares. */
const EMPTY_TRACK = {
days: [],
source: 'tracks',
fetchedAt: NOW_ISO,
pointCount: 0,
truncated: false,
};
/** Unix seconds: what a slim point carries instead of a zoned timestamp. */
const unix = (iso: string): number => Math.floor(Date.parse(iso) / 1000);
/**
* Latitude and longitude go out as strings: that is what `/points?slim=true`
* actually sends, and a reader that only handles numbers is the shape of bug
* this fixture exists to keep out.
*/
const point = (lat: number, lng: number, iso: string): DawarichSlimPoint => ({
latitude: String(lat),
longitude: String(lng),
timestamp: unix(iso),
});
/** One `/tracks` GeoJSON feature, two points wide so it survives simplification. */
function feature(
props: Partial<NonNullable<DawarichTrackFeature['properties']>> = {},
coordinates: unknown = [
[13.0, 52.0],
[13.4, 52.4],
],
): DawarichTrackFeature {
return {
type: 'Feature',
geometry: { type: 'LineString', coordinates },
properties: {
start_at: '2026-05-01T10:00:00+02:00',
end_at: '2026-05-01T12:00:00+02:00',
dominant_mode: 'car',
distance: 41_000,
...props,
},
};
}
interface Collaborators {
/** `undefined` means the caller has no access to the trip. */
access?: unknown;
/** `undefined` means the trip row is gone. */
trip?: { start_date: string | null; end_date: string | null };
creds?: DawarichCreds | null;
capabilities?: DawarichCapabilities | null;
}
function harness(over: Collaborators = {}) {
const canAccessTrip = vi.fn().mockReturnValue('access' in over ? over.access : TRIP_ACCESS);
const get = vi
.fn()
.mockReturnValue(
'trip' in over ? over.trip : { start_date: '2026-05-01', end_date: '2026-05-03' },
);
const getCredentials = vi.fn().mockReturnValue('creds' in over ? over.creds : CREDS);
const getCapabilities = vi.fn().mockReturnValue('capabilities' in over ? over.capabilities : null);
const listTracks = vi.fn().mockResolvedValue({ features: [], truncated: false });
const listPoints = vi.fn().mockResolvedValue({ points: [], truncated: false });
const service = new DawarichTracksService(
{ canAccessTrip, get } as unknown as DatabaseService,
{ getCredentials, getCapabilities } as unknown as DawarichService,
{ listTracks, listPoints } as unknown as DawarichClient,
);
return { service, canAccessTrip, get, getCredentials, getCapabilities, listTracks, listPoints };
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(NOW_ISO));
});
afterEach(() => {
vi.useRealTimers();
});
// ---------------------------------------------------------------------------
// forTrip: access, the trip row, and the window the trip's own dates imply
// ---------------------------------------------------------------------------
describe('DawarichTracksService.forTrip', () => {
it('DAWARICH-TRACKS-001: a trip the caller cannot read is null, and Dawarich is never asked about it', async () => {
const h = harness({ access: undefined });
await expect(h.service.forTrip(USER, 5)).resolves.toBeNull();
// The access check comes first on purpose: reading the trip row before it
// would leak "this trip exists" through timing and through the query log.
expect(h.get).not.toHaveBeenCalled();
expect(h.getCredentials).not.toHaveBeenCalled();
expect(h.listTracks).not.toHaveBeenCalled();
expect(h.canAccessTrip).toHaveBeenCalledWith(5, USER);
});
it('DAWARICH-TRACKS-002: a trip row that is gone is the same null: a member of a deleted trip gets a 404, not a crash', async () => {
const h = harness({ trip: undefined });
await expect(h.service.forTrip(USER, 5)).resolves.toBeNull();
expect(h.listTracks).not.toHaveBeenCalled();
});
it('DAWARICH-TRACKS-003: a trip with no dates is an empty track, not an error: there is no window to ask about', async () => {
const h = harness({ trip: { start_date: null, end_date: null } });
// An error here would read as "your Dawarich connection is broken", which is
// a different and far more alarming claim than "this trip has no dates yet".
await expect(h.service.forTrip(USER, 5)).resolves.toEqual(EMPTY_TRACK);
expect(h.getCredentials).not.toHaveBeenCalled();
expect(h.listTracks).not.toHaveBeenCalled();
});
it('DAWARICH-TRACKS-004: a from without any trip dates still has no end, so it is the empty track too', async () => {
const h = harness({ trip: { start_date: null, end_date: null } });
await expect(h.service.forTrip(USER, 5, '2026-05-01')).resolves.toEqual(EMPTY_TRACK);
expect(h.listTracks).not.toHaveBeenCalled();
});
it("DAWARICH-TRACKS-005: the trip's own dates become a whole-days window, midnight to the last second", async () => {
const h = harness({ trip: { start_date: '2026-05-01', end_date: '2026-05-03' } });
await h.service.forTrip(USER, 5);
expect(h.listTracks).toHaveBeenCalledWith(
CREDS,
new Date('2026-05-01T00:00:00Z'),
new Date('2026-05-03T23:59:59Z'),
);
});
it('DAWARICH-TRACKS-006: a trip with a start and no end is a single day, not an open-ended window', async () => {
const h = harness({ trip: { start_date: '2026-05-01', end_date: null } });
await h.service.forTrip(USER, 5);
expect(h.listTracks).toHaveBeenCalledWith(
CREDS,
new Date('2026-05-01T00:00:00Z'),
new Date('2026-05-01T23:59:59Z'),
);
});
it('DAWARICH-TRACKS-007: from and to narrow the window, so the planner can ask for one day of a fortnight', async () => {
const h = harness({ trip: { start_date: '2026-05-01', end_date: '2026-05-14' } });
await h.service.forTrip(USER, 5, '2026-05-06', '2026-05-06');
expect(h.listTracks).toHaveBeenCalledWith(
CREDS,
new Date('2026-05-06T00:00:00Z'),
new Date('2026-05-06T23:59:59Z'),
);
});
it('DAWARICH-TRACKS-008: a caller that sends no offset gets UTC days', async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
h.listPoints.mockResolvedValue({
points: [point(52.0, 13.0, '2026-05-01T23:30:00Z'), point(52.1, 13.1, '2026-05-01T23:45:00Z')],
truncated: false,
});
const track = await h.service.forTrip(USER, 5, '2026-05-01', '2026-05-01');
expect(track?.days.map((day) => day.date)).toEqual(['2026-05-01']);
});
it("DAWARICH-TRACKS-009: the caller's offset decides the local day: half past eleven in Berlin is already tomorrow", async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
h.listPoints.mockResolvedValue({
points: [point(52.0, 13.0, '2026-05-01T23:30:00Z'), point(52.1, 13.1, '2026-05-01T23:45:00Z')],
truncated: false,
});
const track = await h.service.forTrip(USER, 5, '2026-05-01', '2026-05-01', 120);
// The same two instants as the case above; only the reader's zone changed,
// and that is the whole question the overlay answers.
expect(track?.days.map((day) => day.date)).toEqual(['2026-05-02']);
});
it('DAWARICH-TRACKS-010: forTrip reads the entry forWindow filled: it is a window calculation, not a second path', async () => {
const h = harness({ trip: { start_date: '2026-05-01', end_date: '2026-05-03' } });
const direct = await h.service.forWindow(USER, '2026-05-01T00:00:00Z', '2026-05-03T23:59:59Z', 0);
const viaTrip = await h.service.forTrip(USER, 5);
// Identity, so this cannot pass on two equal-but-separate fetches: the map
// and the trip planner ask the same question about the same trip seconds
// apart, and a private cache on either side doubles every request.
expect(viaTrip).toBe(direct);
expect(h.listTracks).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// forWindow: the connection, the window, and the defaulted offset
// ---------------------------------------------------------------------------
describe('DawarichTracksService.forWindow', () => {
it('DAWARICH-TRACKS-020: a user with no connection gets an empty track rather than a failed request', async () => {
const h = harness({ creds: null });
await expect(
h.service.forWindow(USER, '2026-05-01T00:00:00Z', '2026-05-02T00:00:00Z'),
).resolves.toEqual(EMPTY_TRACK);
expect(h.listTracks).not.toHaveBeenCalled();
expect(h.listPoints).not.toHaveBeenCalled();
});
it.each([
['an unparseable from', 'yesterday', '2026-05-02T00:00:00Z'],
['an unparseable to', '2026-05-01T00:00:00Z', 'tomorrow'],
['an inverted window', '2026-05-02T00:00:00Z', '2026-05-01T00:00:00Z'],
['a zero-length window', '2026-05-01T00:00:00Z', '2026-05-01T00:00:00Z'],
])('DAWARICH-TRACKS-021: %s is an empty track and no upstream call', async (_label, from, to) => {
const h = harness();
await expect(h.service.forWindow(USER, from, to)).resolves.toEqual(EMPTY_TRACK);
expect(h.listTracks).not.toHaveBeenCalled();
expect(h.listPoints).not.toHaveBeenCalled();
});
it('DAWARICH-TRACKS-022: with no offset argument the offset carried by `from` is used', async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
// Just after local midnight in Berlin, which is still the previous day in UTC.
h.listPoints.mockResolvedValue({
points: [point(52.0, 13.0, '2026-04-30T22:10:00Z'), point(52.1, 13.1, '2026-04-30T22:20:00Z')],
truncated: false,
});
const defaulted = await h.service.forWindow(
USER,
'2026-05-01T00:00:00+02:00',
'2026-05-02T00:00:00+02:00',
);
expect(defaulted.days.map((day) => day.date)).toEqual(['2026-05-01']);
// The same window asked with an explicit zero groups the very same instants
// onto the UTC day instead, which is what a caller silent about its zone means.
const utc = await h.service.forWindow(
USER,
'2026-05-01T00:00:00+02:00',
'2026-05-02T00:00:00+02:00',
0,
);
expect(utc.days.map((day) => day.date)).toEqual(['2026-04-30']);
});
it('DAWARICH-TRACKS-023: the window reaches the client as instants, offset resolved', async () => {
const h = harness();
await h.service.forWindow(USER, '2026-05-01T00:00:00+02:00', '2026-05-02T00:00:00+02:00');
expect(h.listTracks).toHaveBeenCalledWith(
CREDS,
new Date('2026-04-30T22:00:00Z'),
new Date('2026-05-01T22:00:00Z'),
);
});
});
// ---------------------------------------------------------------------------
// The cache: key, TTL boundary, eviction, invalidation
// ---------------------------------------------------------------------------
describe('DawarichTracksService cache', () => {
const FROM = '2026-05-01T00:00:00Z';
const TO = '2026-05-02T00:00:00Z';
it('DAWARICH-TRACKS-030: a repeat request inside the TTL is the same object and no second fetch', async () => {
const h = harness();
h.listTracks.mockResolvedValue({ features: [feature()], truncated: false });
const first = await h.service.forWindow(USER, FROM, TO);
const second = await h.service.forWindow(USER, FROM, TO);
// Identity, not equality: panning the map must not rebuild a day of geometry.
expect(second).toBe(first);
expect(h.listTracks).toHaveBeenCalledTimes(1);
});
it('DAWARICH-TRACKS-031: the TTL is exclusive: one millisecond short still hits, sixty seconds exactly refetches', async () => {
const h = harness();
await h.service.forWindow(USER, FROM, TO);
vi.advanceTimersByTime(59_999);
await h.service.forWindow(USER, FROM, TO);
expect(h.listTracks).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
await h.service.forWindow(USER, FROM, TO);
// A live recording has to visibly catch up, so the entry expires rather than
// being refreshed in place.
expect(h.listTracks).toHaveBeenCalledTimes(2);
});
it('DAWARICH-TRACKS-032: the offset is part of the key: the same window grouped two ways is two answers', async () => {
const h = harness();
await h.service.forWindow(USER, FROM, TO, 0);
await h.service.forWindow(USER, FROM, TO, 120);
expect(h.listTracks).toHaveBeenCalledTimes(2);
});
it("DAWARICH-TRACKS-033: the user is part of the key: nobody is served somebody else's route", async () => {
const h = harness();
await h.service.forWindow(1, FROM, TO, 0);
await h.service.forWindow(2, FROM, TO, 0);
expect(h.listTracks).toHaveBeenCalledTimes(2);
});
it('DAWARICH-TRACKS-034: the cache evicts oldest-first at 64 entries, so flipping through a year cannot pin every day in memory', async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
const window = (i: number): [string, string] => {
const start = new Date(Date.UTC(2026, 0, 1) + i * 86_400_000);
return [start.toISOString(), new Date(start.getTime() + 3_600_000).toISOString()];
};
for (let i = 0; i < 65; i++) {
const [from, to] = window(i);
await h.service.forWindow(USER, from, to, 0);
}
expect(h.listPoints).toHaveBeenCalledTimes(65);
// Inserting the 65th pushed the 1st out.
const [firstFrom, firstTo] = window(0);
await h.service.forWindow(USER, firstFrom, firstTo, 0);
expect(h.listPoints).toHaveBeenCalledTimes(66);
// The newest entry survived that eviction: it is the oldest that goes, not
// an arbitrary one.
const [lastFrom, lastTo] = window(64);
await h.service.forWindow(USER, lastFrom, lastTo, 0);
expect(h.listPoints).toHaveBeenCalledTimes(66);
});
it("DAWARICH-TRACKS-035: forget drops the calling user, so a re-pointed connection does not draw the old instance's route", async () => {
const h = harness();
await h.service.forWindow(1, FROM, TO, 0);
h.service.forget(1);
await h.service.forWindow(1, FROM, TO, 0);
expect(h.listTracks).toHaveBeenCalledTimes(2);
});
it('DAWARICH-TRACKS-036: forget matches the whole user segment: forgetting 1 leaves 12 cached', async () => {
const h = harness();
await h.service.forWindow(1, FROM, TO, 0);
await h.service.forWindow(12, FROM, TO, 0);
expect(h.listTracks).toHaveBeenCalledTimes(2);
h.service.forget(1);
// A prefix test without the separator would throw user 12 out as well, and
// the symptom would be "the overlay reloads for no reason", never a failure.
await h.service.forWindow(12, FROM, TO, 0);
expect(h.listTracks).toHaveBeenCalledTimes(2);
});
it('DAWARICH-TRACKS-037: forget for a user with nothing cached leaves everything else alone', async () => {
const h = harness();
await h.service.forWindow(1, FROM, TO, 0);
h.service.forget(99);
await h.service.forWindow(1, FROM, TO, 0);
expect(h.listTracks).toHaveBeenCalledTimes(1);
});
it('DAWARICH-TRACKS-038: the key is the resolved instant, not the string a caller happened to write', async () => {
const h = harness();
await h.service.forWindow(USER, FROM, TO, 0);
// The same two moments, spelled in Berlin local time instead of UTC.
await h.service.forWindow(USER, '2026-05-01T02:00:00+02:00', '2026-05-02T02:00:00+02:00', 0);
// The journal sends what its entries carry and the planner sends `Z`. Keyed
// on the raw text those are two entries for one window, which is both a
// duplicate fetch and twice the memory for the same day of geometry.
expect(h.listTracks).toHaveBeenCalledTimes(1);
});
});
// ---------------------------------------------------------------------------
// fetch: tracks first, points as the fallback
// ---------------------------------------------------------------------------
describe('DawarichTracksService source selection', () => {
const FROM = '2026-05-01T00:00:00Z';
const TO = '2026-05-02T00:00:00Z';
it('DAWARICH-TRACKS-040: tracks answer with the mode and the measured distance, and points are never asked for', async () => {
const h = harness({ capabilities: CAPABILITIES });
h.listTracks.mockResolvedValue({ features: [feature()], truncated: false });
const track = await h.service.forWindow(USER, FROM, TO, 0);
expect(track).toEqual({
days: [
{
date: '2026-05-01',
segments: [
{
points: [
[52.0, 13.0],
[52.4, 13.4],
],
mode: 'car',
// The source's own zoned strings, not a normalised instant: the
// times shown beside the line are the ones Dawarich shows.
startedAt: '2026-05-01T10:00:00+02:00',
endedAt: '2026-05-01T12:00:00+02:00',
distanceMeters: 41_000,
},
],
},
],
source: 'tracks',
fetchedAt: NOW_ISO,
pointCount: 2,
truncated: false,
});
expect(h.listPoints).not.toHaveBeenCalled();
expect(h.getCapabilities).toHaveBeenCalledWith(USER);
});
it('DAWARICH-TRACKS-041: a never-probed connection still tries tracks: unknown is not the same as unsupported', async () => {
const h = harness({ capabilities: null });
h.listTracks.mockResolvedValue({ features: [feature()], truncated: false });
const track = await h.service.forWindow(USER, FROM, TO, 0);
expect(track.source).toBe('tracks');
expect(h.listTracks).toHaveBeenCalledTimes(1);
});
it('DAWARICH-TRACKS-042: a probe that said tracks: false skips the endpoint entirely', async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
h.listPoints.mockResolvedValue({
points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')],
truncated: false,
});
const track = await h.service.forWindow(USER, FROM, TO, 0);
// An instance without /tracks answers 404 to every call; asking anyway would
// put a failed request in front of every single overlay open.
expect(h.listTracks).not.toHaveBeenCalled();
expect(track.source).toBe('points');
expect(track.pointCount).toBe(2);
});
it('DAWARICH-TRACKS-043: an empty tracks list falls through to points: a fresh import has points long before the job has run', async () => {
const h = harness({ capabilities: CAPABILITIES });
h.listTracks.mockResolvedValue({ features: [], truncated: false });
h.listPoints.mockResolvedValue({
points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')],
truncated: false,
});
const track = await h.service.forWindow(USER, FROM, TO, 0);
expect(track.source).toBe('points');
expect(track.days).toHaveLength(1);
expect(h.listPoints).toHaveBeenCalledWith(CREDS, new Date(FROM), new Date(TO));
});
it('DAWARICH-TRACKS-044: a failing tracks endpoint falls through to points instead of failing the overlay', async () => {
const h = harness({ capabilities: CAPABILITIES });
h.listTracks.mockRejectedValue(new DawarichError('not_found', 'no such endpoint', 404));
h.listPoints.mockResolvedValue({
points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')],
truncated: false,
});
const track = await h.service.forWindow(USER, FROM, TO, 0);
expect(track.source).toBe('points');
expect(track.pointCount).toBe(2);
});
it('DAWARICH-TRACKS-045: a failing points endpoint is NOT swallowed: the catch covers tracks only', async () => {
const h = harness({ capabilities: CAPABILITIES });
h.listTracks.mockRejectedValue(new DawarichError('not_found', 'no such endpoint', 404));
h.listPoints.mockRejectedValue(new DawarichError('unauthorized', 'key rejected', 401));
// A genuinely broken connection has to reach the controller as an error. If
// this were caught too, a rejected API key would render as "nothing
// recorded" and nobody would ever find out why the line is missing.
await expect(h.service.forWindow(USER, FROM, TO, 0)).rejects.toThrow('key rejected');
});
it('DAWARICH-TRACKS-046: a failed fetch is not cached, so the next request tries again', async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
h.listPoints.mockRejectedValueOnce(new DawarichError('unreachable', 'timed out'));
h.listPoints.mockResolvedValue({ points: [], truncated: false });
await expect(h.service.forWindow(USER, FROM, TO, 0)).rejects.toThrow('timed out');
await h.service.forWindow(USER, FROM, TO, 0);
expect(h.listPoints).toHaveBeenCalledTimes(2);
});
it('DAWARICH-TRACKS-047: nothing anywhere is an honest empty result that still names the endpoint that answered', async () => {
const h = harness({ capabilities: CAPABILITIES });
const track = await h.service.forWindow(USER, FROM, TO, 0);
// Not EMPTY_TRACK: that one says `tracks` because nothing was asked at all.
// This one went all the way to points, came back with nothing, and says so.
expect(track).toEqual({
days: [],
source: 'points',
fetchedAt: NOW_ISO,
pointCount: 0,
truncated: false,
});
});
it('DAWARICH-TRACKS-048: a truncated tracks page reaches the caller: the line is incomplete and says so', async () => {
const h = harness({ capabilities: CAPABILITIES });
h.listTracks.mockResolvedValue({ features: [feature()], truncated: true });
const track = await h.service.forWindow(USER, FROM, TO, 0);
expect(track).toMatchObject({ source: 'tracks', truncated: true });
});
it('DAWARICH-TRACKS-049: a truncated points page reaches the caller too', async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
h.listPoints.mockResolvedValue({
points: [point(52.0, 13.0, '2026-05-01T10:00:00Z'), point(52.2, 13.2, '2026-05-01T10:10:00Z')],
truncated: true,
});
const track = await h.service.forWindow(USER, FROM, TO, 0);
expect(track).toMatchObject({ source: 'points', truncated: true });
});
it('DAWARICH-TRACKS-050: unusable points are dropped rather than drawn, and pointCount counts what was drawn', async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
h.listPoints.mockResolvedValue({
points: [
point(52.0, 13.0, '2026-05-01T10:00:00Z'),
// A latitude that is not a number at all, and one off the planet. Drawn
// as given, these are a detour to null island and a detour to nowhere.
{ latitude: 'nope', longitude: '13.05', timestamp: unix('2026-05-01T10:05:00Z') },
{ latitude: '95.0', longitude: '13.06', timestamp: unix('2026-05-01T10:06:00Z') },
point(52.2, 13.2, '2026-05-01T10:10:00Z'),
],
truncated: false,
});
const track = await h.service.forWindow(USER, FROM, TO, 0);
expect(track.pointCount).toBe(2);
expect(track.days[0]?.segments[0]).toMatchObject({
points: [
[52.0, 13.0],
[52.2, 13.2],
],
// Points carry no mode and no measured distance. Only tracks do.
mode: null,
startedAt: '2026-05-01T10:00:00.000Z',
endedAt: '2026-05-01T10:10:00.000Z',
distanceMeters: null,
});
});
it('DAWARICH-TRACKS-051: a track feature too thin to draw leaves the day out rather than emitting a one-point line', async () => {
const h = harness({ capabilities: CAPABILITIES });
// One coordinate pair is a dot, not a route. The tracks branch is taken
// anyway because `features.length > 0`, so the empty `days` has to survive
// it rather than silently falling through to points and asking twice.
h.listTracks.mockResolvedValue({ features: [feature({}, [[13.0, 52.0]])], truncated: false });
const track = await h.service.forWindow(USER, FROM, TO, 0);
expect(track).toMatchObject({ source: 'tracks', days: [], pointCount: 0 });
expect(h.listPoints).not.toHaveBeenCalled();
});
it("DAWARICH-TRACKS-052: the credentials handed to the client are the calling user's", async () => {
const h = harness();
await h.service.forWindow(USER, FROM, TO, 0);
expect(h.getCredentials).toHaveBeenCalledWith(USER);
expect(h.listTracks.mock.calls[0][0]).toBe(CREDS);
});
it('DAWARICH-TRACKS-053: points either side of midnight become two days that meet, and pointCount counts what is drawn', async () => {
const h = harness({ capabilities: { ...CAPABILITIES, tracks: false } });
h.listPoints.mockResolvedValue({
points: [
point(52.0, 13.0, '2026-05-01T22:00:00Z'),
point(52.1, 13.1, '2026-05-01T23:00:00Z'),
// Deliberately well off the line between its neighbours: a point that
// simplification would drop as redundant would make the second day two
// points wide and the count below accidentally right.
point(52.5, 13.1, '2026-05-02T01:00:00Z'),
point(52.6, 13.2, '2026-05-02T02:00:00Z'),
],
truncated: false,
});
const track = await h.service.forWindow(USER, '2026-05-01T00:00:00Z', '2026-05-03T00:00:00Z', 0);
expect(track.days.map((day) => day.date)).toEqual(['2026-05-01', '2026-05-02']);
// The last point of the first day opens the second, so the two lines join
// instead of showing a jump across midnight that nobody made...
expect(track.days[1]?.segments[0]?.points[0]).toEqual([52.1, 13.1]);
// ...but it is geometry only. The second day still begins at its own first
// recorded instant, an hour after midnight, not at the stitch.
expect(track.days[1]?.segments[0]?.startedAt).toBe('2026-05-02T01:00:00.000Z');
// Four points upstream, five drawn. The figure beside the overlay describes
// the line on the map, not the size of the archive behind it.
expect(track.pointCount).toBe(5);
});
});