Files
TREK/server/tests/unit/nest/dawarich.controller.test.ts
T

596 lines
30 KiB
TypeScript

/**
* DawarichController unit tests — the controller built with `new`, every
* collaborator a plain mock. No Nest container, no DI, no network: the
* DawarichClient is never constructed here, because the only things under test
* are the ones the controller itself owns.
*
* Those are three:
*
* - **the parsers** (`parseId`, `parseOptionalId`, `parseState`, `parseDate`,
* `parseWindow`), the reason a bad path segment never reaches a query. They
* are module-private on purpose, so they are pinned through the handlers
* that use them rather than exported for a test's convenience;
* - **`toHttp`**, the one place a domain error becomes a status code — an
* `AcceptError` keeps the status the domain chose, a `DawarichError` becomes
* 502 except for the two codes that mean "your key is wrong", which are 400
* because they are the caller's to fix and not the upstream's fault;
* - **the delegation**, `X-Socket-Id` included, whose loss would make an
* accept echo straight back into the tab that performed it.
*/
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
import type { DawarichErrorCode } from '@trek/shared';
import { DawarichController } from '../../../src/nest/integrations/dawarich.controller';
import { AcceptError } from '../../../src/nest/integrations/dawarich-suggestions.service';
import { DawarichError } from '../../../src/nest/integrations/dawarich.client';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
import { ADDON_IDS } from '../../../src/addons';
import type { DawarichService } from '../../../src/nest/integrations/dawarich.service';
import type { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service';
import type { DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service';
import type { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service';
import type { User } from '../../../src/types';
const user = { id: 7 } as User;
interface Mocks {
dawarich?: Partial<DawarichService>;
sync?: Partial<DawarichSyncService>;
suggestions?: Partial<DawarichSuggestionsService>;
tracks?: Partial<DawarichTracksService>;
}
function makeController(m: Mocks = {}) {
return new DawarichController(
(m.dawarich ?? {}) as DawarichService,
(m.sync ?? {}) as DawarichSyncService,
(m.suggestions ?? {}) as DawarichSuggestionsService,
(m.tracks ?? {}) as DawarichTracksService,
);
}
/** A request stub carrying only what the controller reads off it. */
function makeReq(headers: Record<string, string> = {}, ip = '10.1.2.3'): Request {
return { headers, ip, socket: { remoteAddress: ip } } as unknown as Request;
}
/** A valid settings body — the two defaulted fields are required in the DTO's output type. */
function settings(over: Partial<{ url: string; apiKey: string; allowInsecureTls: boolean; syncEnabled: boolean }> = {}) {
return {
url: 'https://dawarich.example',
allowInsecureTls: false,
syncEnabled: true,
...over,
};
}
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected the handler to throw');
}
// ---------------------------------------------------------------------------
// Route wiring
// ---------------------------------------------------------------------------
describe('DawarichController wiring', () => {
it('DAWARICH-CTRL-001: answers under /api/integrations/dawarich', () => {
expect(Reflect.getMetadata('path', DawarichController)).toBe('api/integrations/dawarich');
});
it('DAWARICH-CTRL-002: AddonGuard runs BEFORE JwtAuthGuard, so a disabled addon 404s an anonymous caller instead of confirming the route with a 401', () => {
const guards = Reflect.getMetadata('__guards__', DawarichController) as unknown[] | undefined;
// Array shape asserted first: expect(undefined).toContain(X) passes in this
// vitest version, so the plain form would stop guarding the moment the
// decorator disappeared.
expect(Array.isArray(guards)).toBe(true);
expect(guards).toEqual([AddonGuard, JwtAuthGuard]);
});
it('DAWARICH-CTRL-003: gates on the dawarich addon, with the label that leads the 404 body', () => {
expect(Reflect.getMetadata(REQUIRE_ADDON, DawarichController)).toEqual({
addonId: ADDON_IDS.DAWARICH,
label: 'Dawarich',
});
});
});
// ---------------------------------------------------------------------------
// Connection routes
// ---------------------------------------------------------------------------
describe('DawarichController connection routes', () => {
it('DAWARICH-CTRL-010: GET settings hands back what the service says, for the calling user only', async () => {
const connection = { url: 'https://d.example', connected: true };
const getConnection = vi.fn().mockReturnValue(connection);
expect(await makeController({ dawarich: { getConnection } }).getSettings(user)).toBe(connection);
expect(getConnection).toHaveBeenCalledWith(7);
});
it('DAWARICH-CTRL-011: PUT settings forwards url, key, tls flag, syncEnabled and the client ip', async () => {
const saveSettings = vi.fn().mockResolvedValue({ success: true });
const forget = vi.fn();
const c = makeController({ dawarich: { saveSettings }, tracks: { forget } });
const out = await c.putSettings(
user,
settings({ apiKey: 'k', allowInsecureTls: true, syncEnabled: false }),
makeReq({}, '203.0.113.9'),
);
expect(out).toEqual({ success: true });
expect(saveSettings).toHaveBeenCalledWith(7, 'https://dawarich.example', 'k', true, false, '203.0.113.9');
});
it('DAWARICH-CTRL-012: only an explicit false turns the background poll off', async () => {
const saveSettings = vi.fn().mockResolvedValue({ success: true });
const c = makeController({ dawarich: { saveSettings }, tracks: { forget: vi.fn() } });
await c.putSettings(user, settings({ syncEnabled: true }), makeReq());
expect(saveSettings.mock.calls[0][4]).toBe(true);
});
it('DAWARICH-CTRL-013: a rejected URL is a 400 carrying the service reason, and the cached track is left alone', async () => {
const saveSettings = vi.fn().mockResolvedValue({ success: false, error: 'Invalid URL' });
const forget = vi.fn();
const c = makeController({ dawarich: { saveSettings }, tracks: { forget } });
expect(await thrown(() => c.putSettings(user, settings({ url: 'nope' }), makeReq())))
.toEqual({ status: 400, body: { error: 'Invalid URL' } });
expect(forget).not.toHaveBeenCalled();
});
it("DAWARICH-CTRL-014: a saved connection drops the cached track, so the old instance's route is not drawn under the new one's name", async () => {
const forget = vi.fn();
const c = makeController({
dawarich: { saveSettings: vi.fn().mockResolvedValue({ success: true }) },
tracks: { forget },
});
await c.putSettings(user, settings(), makeReq());
expect(forget).toHaveBeenCalledWith(7);
});
it('DAWARICH-CTRL-015: a private-IP warning reaches the caller alongside the success', async () => {
const c = makeController({
dawarich: { saveSettings: vi.fn().mockResolvedValue({ success: true, warning: 'resolves to a private IP' }) },
tracks: { forget: vi.fn() },
});
expect(await c.putSettings(user, settings({ url: 'http://192.168.0.5:3000' }), makeReq()))
.toEqual({ success: true, warning: 'resolves to a private IP' });
});
it('DAWARICH-CTRL-016: DELETE settings disconnects, forgets the track and answers 200 { success: true }', async () => {
const disconnect = vi.fn();
const forget = vi.fn();
const c = makeController({ dawarich: { disconnect }, tracks: { forget } });
expect(await c.disconnect(user, makeReq({}, '198.51.100.4'))).toEqual({ success: true });
expect(disconnect).toHaveBeenCalledWith(7, '198.51.100.4');
expect(forget).toHaveBeenCalledWith(7);
});
it('DAWARICH-CTRL-017: POST test answers the probe verbatim — an unusable connection is a 200 the form can render, not an exception', async () => {
const status = { connected: false, error: 'unauthorized' };
const testConnection = vi.fn().mockResolvedValue(status);
const c = makeController({ dawarich: { testConnection } });
await expect(c.test(user, settings({ apiKey: 'wrong' }))).resolves.toBe(status);
expect(testConnection).toHaveBeenCalledWith(7, 'https://dawarich.example', 'wrong', false);
});
it('DAWARICH-CTRL-018: POST sync pulls for the caller and returns the counts', async () => {
const result = { state: 'ok', created: 2, updated: 1, missing: 0 };
const syncUser = vi.fn().mockResolvedValue(result);
await expect(makeController({ sync: { syncUser } }).syncNow(user)).resolves.toBe(result);
expect(syncUser).toHaveBeenCalledWith(7);
});
});
// ---------------------------------------------------------------------------
// parseOptionalId / parseState — the suggestions list filters
// ---------------------------------------------------------------------------
describe('DawarichController GET /suggestions filters', () => {
it('DAWARICH-CTRL-020: no filters means both are undefined', async () => {
const list = vi.fn().mockReturnValue({ suggestions: [] });
await makeController({ suggestions: { list } }).listSuggestions(user);
expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined });
});
it('DAWARICH-CTRL-021: an empty query value reads as "no filter", not as a filter on nothing', async () => {
const list = vi.fn().mockReturnValue({ suggestions: [] });
await makeController({ suggestions: { list } }).listSuggestions(user, '', '');
expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined });
});
it('DAWARICH-CTRL-022: a numeric tripId arrives as a number', async () => {
const list = vi.fn().mockReturnValue({ suggestions: [] });
await makeController({ suggestions: { list } }).listSuggestions(user, '42', 'new');
expect(list).toHaveBeenCalledWith(7, { tripId: 42, state: 'new' });
});
it('DAWARICH-CTRL-023: 400 on a tripId that merely starts with digits, and the field is named', () => {
const list = vi.fn();
return thrown(() => makeController({ suggestions: { list } }).listSuggestions(user, '12abc')).then((r) => {
expect(r).toEqual({ status: 400, body: { error: 'Invalid tripId' } });
expect(list).not.toHaveBeenCalled();
});
});
it.each(['new', 'accepted', 'dismissed'])('DAWARICH-CTRL-024: %s is a known state and passes through', async (state) => {
const list = vi.fn().mockReturnValue({ suggestions: [] });
await makeController({ suggestions: { list } }).listSuggestions(user, undefined, state);
expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state });
});
it.each(['NEW', 'pending', 'all', "new' OR 1=1"])(
'DAWARICH-CTRL-025: 400 on the unknown state %j rather than a silently unfiltered list',
(state) => {
const list = vi.fn();
return thrown(() => makeController({ suggestions: { list } }).listSuggestions(user, undefined, state)).then((r) => {
expect(r).toEqual({ status: 400, body: { error: 'state must be one of: new, accepted, dismissed' } });
expect(list).not.toHaveBeenCalled();
});
},
);
});
// ---------------------------------------------------------------------------
// parseId — every route that takes an id in the path
// ---------------------------------------------------------------------------
describe('DawarichController parseId', () => {
const bad = ['12abc', 'abc', '', ' 12', '12 ', '1.5', '-3', '0', '+7', '1e3', '0x10', '9007199254740993'];
it.each(bad)('DAWARICH-CTRL-030: POST accept refuses the id %j with 400 and never reaches the service', (raw) => {
const accept = vi.fn();
return thrown(() => makeController({ suggestions: { accept } })
.acceptSuggestion(user, raw, { target: 'place' }, makeReq())).then((r) => {
expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } });
expect(accept).not.toHaveBeenCalled();
});
});
it('DAWARICH-CTRL-031: "12abc" is never read as 12 — this is exactly what parseInt would have let through', () => {
const setState = vi.fn();
return thrown(() => makeController({ suggestions: { setState } })
.setSuggestionState(user, '12abc', { state: 'dismissed' })).then((r) => {
expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } });
expect(setState).not.toHaveBeenCalled();
});
});
it('DAWARICH-CTRL-032: a plain positive integer is accepted, leading zeroes included', async () => {
const setState = vi.fn().mockReturnValue({ id: 12 });
const c = makeController({ suggestions: { setState } });
expect(await c.setSuggestionState(user, '012', { state: 'new' })).toEqual({ id: 12 });
expect(setState).toHaveBeenCalledWith(7, 12, 'new');
});
it('DAWARICH-CTRL-033: DELETE bucket-list/:itemId/visit refuses a non-numeric item id with 400', () => {
const clearBucketVisit = vi.fn();
return thrown(() => makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '12abc')).then((r) => {
expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } });
expect(clearBucketVisit).not.toHaveBeenCalled();
});
});
it('DAWARICH-CTRL-034: GET trips/:tripId/track refuses a non-numeric trip id with 400, not with the guard\'s 502', async () => {
const forTrip = vi.fn();
expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '12abc')))
.toEqual({ status: 400, body: { error: 'Invalid id' } });
expect(forTrip).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Suggestion state, accept, bucket list, atlas
// ---------------------------------------------------------------------------
describe('DawarichController suggestion routes', () => {
it('DAWARICH-CTRL-040: PUT state returns the updated suggestion', async () => {
const updated = { id: 3, state: 'dismissed' };
const setState = vi.fn().mockReturnValue(updated);
expect(await makeController({ suggestions: { setState } }).setSuggestionState(user, '3', { state: 'dismissed' })).toBe(updated);
expect(setState).toHaveBeenCalledWith(7, 3, 'dismissed');
});
it("DAWARICH-CTRL-041: PUT state on somebody else's suggestion is the same 404 a missing one gets — no enumeration", () => {
const setState = vi.fn().mockReturnValue(null);
return thrown(() => makeController({ suggestions: { setState } }).setSuggestionState(user, '999', { state: 'new' })).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Suggestion not found' } }));
});
it('DAWARICH-CTRL-042: POST accept forwards X-Socket-Id, so the broadcast does not echo into the originating tab', async () => {
const accept = vi.fn().mockReturnValue({ createdPlaceId: 5 });
const body = { target: 'place' as const, tripId: 2 };
await makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', body, makeReq({ 'x-socket-id': 'sock-abc' }));
expect(accept).toHaveBeenCalledWith(7, 9, body, 'sock-abc');
});
it('DAWARICH-CTRL-043: no X-Socket-Id means undefined, not an empty string a broadcaster would compare against', async () => {
const accept = vi.fn().mockReturnValue({});
await makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq());
expect(accept.mock.calls[0][3]).toBeUndefined();
});
it('DAWARICH-CTRL-044: a blank X-Socket-Id counts as absent', async () => {
const accept = vi.fn().mockReturnValue({});
await makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq({ 'x-socket-id': '' }));
expect(accept.mock.calls[0][3]).toBeUndefined();
});
it('DAWARICH-CTRL-045: POST bucket-list/confirm wraps the count', async () => {
const confirmBucketVisits = vi.fn().mockReturnValue(3);
expect(await makeController({ suggestions: { confirmBucketVisits } })
.confirmBucketVisits(user, { itemIds: [1, 2, 3], visitedAt: '2026-05-01' })).toEqual({ updated: 3 });
expect(confirmBucketVisits).toHaveBeenCalledWith(7, [1, 2, 3], '2026-05-01');
});
it('DAWARICH-CTRL-046: DELETE bucket-list/:itemId/visit is a 404 when nothing was cleared', () => {
const clearBucketVisit = vi.fn().mockReturnValue(false);
return thrown(() => makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Bucket-list entry not found' } }));
});
it('DAWARICH-CTRL-076: DELETE bucket-list/:itemId/visit answers 200 { success: true } when a tick was cleared', async () => {
// The counterpart to 046: the service reports whether a row changed, and
// only the "nothing changed" answer is a 404. A cleared tick must not come
// back as one, or undoing a wish would look like it failed.
const clearBucketVisit = vi.fn().mockReturnValue(true);
expect(await makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).toEqual({
success: true,
});
expect(clearBucketVisit).toHaveBeenCalledWith(7, 4);
});
it('DAWARICH-CTRL-047: POST atlas/accept wraps the marked count', async () => {
const acceptAtlasCountries = vi.fn().mockReturnValue(2);
expect(await makeController({ suggestions: { acceptAtlasCountries } })
.acceptAtlasCountries(user, { countryCodes: ['DE', 'FR'] })).toEqual({ marked: 2 });
expect(acceptAtlasCountries).toHaveBeenCalledWith(7, ['DE', 'FR']);
});
it('DAWARICH-CTRL-048: POST bucket-list/scan returns the scan untouched', async () => {
const scan = { items: [], connected: true };
const scanBucketList = vi.fn().mockResolvedValue(scan);
await expect(makeController({ suggestions: { scanBucketList } }).scanBucketList(user)).resolves.toBe(scan);
expect(scanBucketList).toHaveBeenCalledWith(7);
});
});
// ---------------------------------------------------------------------------
// toHttp — the one place a domain error becomes a status
// ---------------------------------------------------------------------------
describe('DawarichController error shaping', () => {
it('DAWARICH-CTRL-050: an AcceptError keeps the 404 the domain chose', () => {
const accept = vi.fn(() => { throw new AcceptError('not_found', 'Suggestion not found', 404); });
return thrown(() => makeController({ suggestions: { accept } })
.acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) =>
expect(r).toEqual({ status: 404, body: { error: 'Suggestion not found', code: 'not_found' } }));
});
it('DAWARICH-CTRL-051: an AcceptError keeps a 409 too — the status travels with the error, it is not re-derived here', () => {
const accept = vi.fn(() => { throw new AcceptError('already_accepted', 'Suggestion has already been accepted', 409); });
return thrown(() => makeController({ suggestions: { accept } })
.acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) =>
expect(r).toEqual({ status: 409, body: { error: 'Suggestion has already been accepted', code: 'already_accepted' } }));
});
it('DAWARICH-CTRL-052: an AcceptError keeps a 400 (a day that is not on the trip)', () => {
const accept = vi.fn(() => { throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400); });
return thrown(() => makeController({ suggestions: { accept } })
.acceptSuggestion(user, '9', { target: 'place', dayId: 4 }, makeReq())).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'Day does not belong to this trip', code: 'day_not_on_trip' } }));
});
const upstreamCodes: DawarichErrorCode[] = [
'unreachable',
'not_found',
'rate_limited',
'server_error',
'invalid_response',
'too_large',
'not_connected',
];
it.each(upstreamCodes)(
'DAWARICH-CTRL-053: a DawarichError with code %s becomes a 502 — the failure is a server TREK called, not TREK itself',
async (code) => {
const scanBucketList = vi.fn().mockRejectedValue(new DawarichError(code, 'upstream said no'));
expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user)))
.toEqual({ status: 502, body: { error: 'upstream said no', code } });
},
);
const callerCodes: DawarichErrorCode[] = ['unauthorized', 'forbidden'];
it.each(callerCodes)(
"DAWARICH-CTRL-054: a DawarichError with code %s becomes a 400 — a rejected key is the caller's to fix, not a gateway fault",
async (code) => {
const scanBucketList = vi.fn().mockRejectedValue(new DawarichError(code, 'key rejected', 401));
expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user)))
.toEqual({ status: 400, body: { error: 'key rejected', code } });
},
);
it('DAWARICH-CTRL-055: the code reaches the client for i18n, and `detail` rides along only when there is one', async () => {
const scanBucketList = vi.fn().mockRejectedValue(
new DawarichError('invalid_response', 'Unexpected response', 200, '<html>login</html>'),
);
expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user)))
.toEqual({ status: 502, body: { error: 'Unexpected response', code: 'invalid_response', detail: '<html>login</html>' } });
});
it('DAWARICH-CTRL-056: an HttpException raised inside the guarded call passes through untouched', async () => {
const forTrip = vi.fn().mockRejectedValue(new HttpException({ error: 'Trip not found' }, 404));
expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '3')))
.toEqual({ status: 404, body: { error: 'Trip not found' } });
});
it('DAWARICH-CTRL-057: anything else becomes a generic 502 rather than leaking the message', async () => {
const atlasSuggestions = vi.fn().mockRejectedValue(new Error('ECONNRESET on /api/v1/visits?api_key=secret'));
expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } })
.atlasSuggestions(user, '2026-01-01T00:00:00Z', '2026-01-05T00:00:00Z')))
.toEqual({ status: 502, body: { error: 'Dawarich request failed', code: 'server_error' } });
});
it('DAWARICH-CTRL-058: the synchronous guard shapes a throw from accept exactly as the async one does', () => {
const accept = vi.fn(() => { throw new DawarichError('unauthorized', 'key rejected'); });
return thrown(() => makeController({ suggestions: { accept } })
.acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) =>
expect(r).toEqual({ status: 400, body: { error: 'key rejected', code: 'unauthorized' } }));
});
});
// ---------------------------------------------------------------------------
// parseWindow — the explicit instant range
// ---------------------------------------------------------------------------
describe('DawarichController parseWindow', () => {
const halfOpen: Array<[string | undefined, string | undefined]> = [
[undefined, undefined],
['2026-01-01T00:00:00Z', undefined],
[undefined, '2026-01-05T00:00:00Z'],
['', '2026-01-05T00:00:00Z'],
['2026-01-01T00:00:00Z', ''],
['nonsense', '2026-01-05T00:00:00Z'],
['2026-01-01T00:00:00Z', 'nonsense'],
];
it.each(halfOpen)(
'DAWARICH-CTRL-060: GET atlas/suggestions refuses the window (%j, %j) with 400 — one open end is "everything since the epoch"',
async (from, to) => {
const atlasSuggestions = vi.fn();
expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } }).atlasSuggestions(user, from, to)))
.toEqual({ status: 400, body: { error: 'from and to are required ISO timestamps' } });
expect(atlasSuggestions).not.toHaveBeenCalled();
},
);
it.each(halfOpen)('DAWARICH-CTRL-061: GET track refuses the window (%j, %j) with 400', async (from, to) => {
const forWindow = vi.fn();
expect(await thrown(() => makeController({ tracks: { forWindow } }).windowTrack(user, from, to)))
.toEqual({ status: 400, body: { error: 'from and to are required ISO timestamps' } });
expect(forWindow).not.toHaveBeenCalled();
});
it('DAWARICH-CTRL-062: an inverted window is a 400', async () => {
expect(await thrown(() => makeController({ suggestions: { atlasSuggestions: vi.fn() } })
.atlasSuggestions(user, '2026-02-01T00:00:00Z', '2026-01-01T00:00:00Z')))
.toEqual({ status: 400, body: { error: 'to must be after from' } });
});
it('DAWARICH-CTRL-063: a zero-length window is a 400 — "after from", not "at or after"', async () => {
expect(await thrown(() => makeController({ tracks: { forWindow: vi.fn() } })
.windowTrack(user, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')))
.toEqual({ status: 400, body: { error: 'to must be after from' } });
});
it('DAWARICH-CTRL-064: exactly 400 days is still allowed', async () => {
const atlasSuggestions = vi.fn().mockResolvedValue({ countries: [] });
const from = new Date('2026-01-01T00:00:00Z');
const to = new Date(from.getTime() + 400 * 86_400_000);
await makeController({ suggestions: { atlasSuggestions } }).atlasSuggestions(user, from.toISOString(), to.toISOString());
expect(atlasSuggestions).toHaveBeenCalledWith(7, from, to);
});
it('DAWARICH-CTRL-065: one millisecond past 400 days is a 400, so nobody can ask an instance to time out', async () => {
const atlasSuggestions = vi.fn();
const from = new Date('2026-01-01T00:00:00Z');
const to = new Date(from.getTime() + 400 * 86_400_000 + 1);
expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } })
.atlasSuggestions(user, from.toISOString(), to.toISOString())))
.toEqual({ status: 400, body: { error: 'Window must be 400 days or less' } });
expect(atlasSuggestions).not.toHaveBeenCalled();
});
it('DAWARICH-CTRL-066: GET track normalises both ends to ISO before handing them on', async () => {
const forWindow = vi.fn().mockResolvedValue({ days: [] });
await makeController({ tracks: { forWindow } }).windowTrack(user, '2026-01-01T00:00:00+02:00', '2026-01-02T00:00:00+02:00');
// The trailing 0 is the caller's UTC offset: absent means UTC, which is
// what this endpoint grouped by before it asked.
expect(forWindow).toHaveBeenCalledWith(7, '2025-12-31T22:00:00.000Z', '2026-01-01T22:00:00.000Z', 0);
});
});
// ---------------------------------------------------------------------------
// parseDate — the trip-track narrowing
// ---------------------------------------------------------------------------
describe('DawarichController GET /trips/:tripId/track', () => {
it('DAWARICH-CTRL-070: no narrowing means the service decides the window from the trip', async () => {
const forTrip = vi.fn().mockResolvedValue({ days: [] });
await makeController({ tracks: { forTrip } }).tripTrack(user, '5');
expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 0);
});
it('DAWARICH-CTRL-071: an empty from/to is "no narrowing", not an empty date', async () => {
const forTrip = vi.fn().mockResolvedValue({ days: [] });
await makeController({ tracks: { forTrip } }).tripTrack(user, '5', '', '');
expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 0);
});
it('DAWARICH-CTRL-072: YYYY-MM-DD passes through', async () => {
const forTrip = vi.fn().mockResolvedValue({ days: [] });
await makeController({ tracks: { forTrip } }).tripTrack(user, '5', '2026-05-01', '2026-05-03');
expect(forTrip).toHaveBeenCalledWith(7, 5, '2026-05-01', '2026-05-03', 0);
});
it('DAWARICH-CTRL-074: the offset from the caller decides which day a point belongs to', async () => {
const forTrip = vi.fn().mockResolvedValue({ days: [] });
await makeController({ tracks: { forTrip } }).tripTrack(user, '5', undefined, undefined, '120');
expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 120);
});
it.each([
['9999', 840],
['-9999', -720],
['not-a-number', 0],
['', 0],
['-210.9', -210],
])('DAWARICH-CTRL-075: the offset %j is bounded to the real world as %i', async (raw, expected) => {
// A junk offset must not be able to shift a day by a week; the bounds are
// the actual range of world offsets, -12h to +14h.
const forTrip = vi.fn().mockResolvedValue({ days: [] });
await makeController({ tracks: { forTrip } }).tripTrack(user, '5', undefined, undefined, raw);
expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, expected);
});
it.each(['2026-5-1', '01.05.2026', '2026-05-01T00:00:00Z', 'yesterday'])(
'DAWARICH-CTRL-073: 400 on the non-ISO date %j',
async (raw) => {
const forTrip = vi.fn();
expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '5', raw)))
.toEqual({ status: 400, body: { error: 'Dates must be ISO, YYYY-MM-DD' } });
expect(forTrip).not.toHaveBeenCalled();
},
);
it('DAWARICH-CTRL-077: a trip the caller cannot read is a 404 (the service says null, the controller says not found)', async () => {
const forTrip = vi.fn().mockResolvedValue(null);
expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '5')))
.toEqual({ status: 404, body: { error: 'Trip not found' } });
});
it('DAWARICH-CTRL-078: a track comes back untouched', async () => {
const track = { days: [{ date: '2026-05-01', segments: [] }], truncated: false };
const forTrip = vi.fn().mockResolvedValue(track);
await expect(makeController({ tracks: { forTrip } }).tripTrack(user, '5')).resolves.toBe(track);
});
});