Files
TREK/server/tests/unit/nest/roadtrip.controller.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

249 lines
12 KiB
TypeScript

/**
* ROADTRIP-CTL-001..016 — the seven via routes.
*
* What is worth pinning here is not the happy path, which the service tests
* already cover, but the checks a later change could delete without anything
* noticing: the guard chain, the day-belongs-to-this-trip test on every route,
* the track-belongs-to-this-trip test on the batch route, and the exact 404
* bodies the client reads.
*
* The guard chain is asserted through the decorator metadata rather than by
* booting Nest: the controller's own comment calls the ORDER load-bearing, and
* a class-level assertion is the only thing that fails when somebody removes
* AddonGuard again. `@RequireAddon` is metadata and does nothing on its own, so
* dropping the guard from the list is a silent regression that opens all seven
* routes on an instance that switched the addon off.
*/
import { AddonGuard } from '../../../src/nest/addons/addon.guard';
import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard';
import { TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard';
import { RoadtripController } from '../../../src/nest/roadtrip/roadtrip.controller';
import type { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
import { HttpException } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { describe, it, expect, vi } from 'vitest';
const VIA = { id: 5, day_id: 4, after_order_index: 0, sequence: 0, lat: 53, lng: 10 };
function svc(o: Partial<RoadtripService> = {}): RoadtripService {
return {
dayExists: vi.fn().mockReturnValue(true),
trackExists: vi.fn().mockReturnValue(true),
listForDay: vi.fn().mockReturnValue([VIA]),
listForTrip: vi.fn().mockReturnValue([VIA]),
tracksForTrip: vi.fn().mockReturnValue([]),
create: vi.fn().mockReturnValue(VIA),
createMany: vi.fn().mockReturnValue([VIA]),
reanchor: vi.fn().mockReturnValue([VIA]),
move: vi.fn().mockReturnValue(VIA),
remove: vi.fn().mockReturnValue(true),
// Every write announces the day's new shape to the trip's other clients.
broadcast: vi.fn(),
...o,
} as unknown as RoadtripService;
}
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 throw');
}
describe('RoadtripController — the guard chain', () => {
it('ROADTRIP-CTL-001: the addon gate leads, and the auth and trip guards follow', () => {
const guards = Reflect.getMetadata(GUARDS_METADATA, RoadtripController) as unknown[];
// AddonGuard first, and present at all: @RequireAddon is metadata and inert
// without it, and the guard is not registered globally either. Without this
// line all seven routes answered on an instance that had the addon off,
// which is the bug this branch fixed once already.
expect(guards).toEqual([AddonGuard, JwtAuthGuard, TripAccessGuard]);
// It leads because a disabled addon owes an anonymous caller a 404 rather
// than a 401 that tells them the route exists.
expect(guards[0]).toBe(AddonGuard);
});
});
describe('RoadtripController — reads', () => {
it('ROADTRIP-CTL-002: the trip read hands back vias and tracks in one answer', async () => {
const s = svc();
expect(await new RoadtripController(s).listAll('7')).toEqual({ vias: [VIA], tracks: [] });
expect(s.listForTrip).toHaveBeenCalledWith('7');
expect(s.tracksForTrip).toHaveBeenCalledWith('7');
});
it('ROADTRIP-CTL-003: the day read checks the day belongs to this trip', async () => {
const s = svc();
expect(await new RoadtripController(s).list('7', '4')).toEqual({ vias: [VIA] });
expect(s.dayExists).toHaveBeenCalledWith('4', '7');
});
it('ROADTRIP-CTL-004: a day from another trip is not readable through this one', async () => {
// The guard proves the caller may reach the trip; this proves the day is
// part of it. Without the check a valid day id from somebody else's trip is
// reachable by way of a trip the caller does have access to.
const s = svc({ dayExists: vi.fn().mockReturnValue(false) });
expect(await thrown(() => new RoadtripController(s).list('7', '999'))).toEqual({
status: 404,
body: { error: 'Day not found' },
});
});
});
describe('RoadtripController — writes', () => {
const body = { after_order_index: 0, lat: 53, lng: 10 };
it('ROADTRIP-CTL-005: creating one checks the day first', async () => {
const s = svc();
expect(await new RoadtripController(s).create('7', '4', body)).toEqual({ via: VIA });
expect(s.dayExists).toHaveBeenCalledWith('4', '7');
expect(s.create).toHaveBeenCalledWith('4', body);
});
it('ROADTRIP-CTL-006: every write refuses a day from another trip', async () => {
const s = svc({ dayExists: vi.fn().mockReturnValue(false) });
const c = new RoadtripController(s);
const notFound = { status: 404, body: { error: 'Day not found' } };
expect(await thrown(() => c.create('7', '999', body))).toEqual(notFound);
expect(await thrown(() => c.createMany('7', '999', { vias: [body] }))).toEqual(notFound);
expect(await thrown(() => c.reanchor('7', '999', { vias: [] }))).toEqual(notFound);
expect(await thrown(() => c.update('7', '999', '5', { lat: 1, lng: 2 }))).toEqual(notFound);
expect(await thrown(() => c.remove('7', '999', '5'))).toEqual(notFound);
// None of them reached the service.
expect(s.create).not.toHaveBeenCalled();
expect(s.createMany).not.toHaveBeenCalled();
expect(s.reanchor).not.toHaveBeenCalled();
expect(s.move).not.toHaveBeenCalled();
expect(s.remove).not.toHaveBeenCalled();
});
it('ROADTRIP-CTL-007: a batch lays the whole chain in one call', async () => {
const s = svc();
const batch = { vias: [body, body], replace_legs: [0] };
expect(await new RoadtripController(s).createMany('7', '4', batch)).toEqual({ vias: [VIA] });
expect(s.createMany).toHaveBeenCalledWith('4', batch);
});
it('ROADTRIP-CTL-008: a track from another trip cannot become this day label', async () => {
// Permission is not enough on its own: a place id from somebody else's trip
// would otherwise become this day's label, and a place that is not a track
// would become a label that can never be drawn.
const s = svc({ trackExists: vi.fn().mockReturnValue(false) });
expect(
await thrown(() => new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } })),
).toEqual({ status: 404, body: { error: 'Track not found' } });
expect(s.createMany).not.toHaveBeenCalled();
});
it('ROADTRIP-CTL-009: the track check runs against the trip, not the day', async () => {
const s = svc();
await new RoadtripController(s).createMany('7', '4', { vias: [body], track: { place_id: 3 } });
expect(s.trackExists).toHaveBeenCalledWith(3, '7');
});
it('ROADTRIP-CTL-010: clearing a day track is not a track to look up', async () => {
// `null` says the day follows nothing any more, so there is no place id to
// check the existence of. Treating it as one would refuse the only way to
// detach a track.
const s = svc({ trackExists: vi.fn().mockReturnValue(false) });
expect(await new RoadtripController(s).createMany('7', '4', { vias: [body], track: null })).toEqual({
vias: [VIA],
});
expect(s.trackExists).not.toHaveBeenCalled();
});
it('ROADTRIP-CTL-011: re-anchoring goes through as one batch', async () => {
const s = svc();
const plan = { vias: [{ id: 5, after_order_index: 1 }], remove: [6] };
expect(await new RoadtripController(s).reanchor('7', '4', plan)).toEqual({ vias: [VIA] });
expect(s.reanchor).toHaveBeenCalledWith('4', plan);
});
it('ROADTRIP-CTL-012: moving a via reports where it went', async () => {
const s = svc();
expect(await new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11 })).toEqual({ via: VIA });
// Undefined anchor, explicitly: a drag that stayed between the same two stops sends
// no new one, and the service must leave the existing pin alone rather than clear it.
expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11, undefined);
});
it('ROADTRIP-CTL-015: a new anchor is passed on, so a via dragged past a stop is re-pinned', async () => {
const s = svc();
await new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11, after_order_index: 2 });
expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11, 2);
});
it('ROADTRIP-CTL-013: a via that is not on this day is a 404, not a silent no-op', async () => {
const s = svc({ move: vi.fn().mockReturnValue(null) });
expect(await thrown(() => new RoadtripController(s).update('7', '4', '999', { lat: 1, lng: 2 }))).toEqual({
status: 404,
body: { error: 'Via not found' },
});
});
it('ROADTRIP-CTL-014: removing one reports success', async () => {
const s = svc();
expect(await new RoadtripController(s).remove('7', '4', '5')).toEqual({ success: true });
expect(s.remove).toHaveBeenCalledWith('5', '4');
});
it('ROADTRIP-CTL-015: removing a via that is not there is a 404', async () => {
const s = svc({ remove: vi.fn().mockReturnValue(false) });
expect(await thrown(() => new RoadtripController(s).remove('7', '4', '999'))).toEqual({
status: 404,
body: { error: 'Via not found' },
});
});
/**
* Two people planning one road trip look at the same line on the same map, and a
* reshaped drive moves every arrival time after it. These routes used to write in
* silence, which left the other side reading a route nobody could see change.
*/
describe('telling the trip', () => {
it('SRV-ROADTRIP-020: every write announces the new shape of the day', async () => {
const cases: [string, (c: RoadtripController) => unknown][] = [
['create', (c) => c.create('7', '4', { after_order_index: 0, lat: 1, lng: 2 }, 'sock')],
['createMany', (c) => c.createMany('7', '4', { vias: [{ after_order_index: 0, lat: 1, lng: 2 }] }, 'sock')],
['reanchor', (c) => c.reanchor('7', '4', { vias: [] }, 'sock')],
['update', (c) => c.update('7', '4', '9', { lat: 1, lng: 2 }, 'sock')],
['remove', (c) => c.remove('7', '4', '9', 'sock')],
];
for (const [name, run] of cases) {
const s = svc();
await run(new RoadtripController(s));
expect(s.broadcast, name).toHaveBeenCalledWith('7', 'roadtripVia:changed', { dayId: '4', vias: [VIA] }, 'sock');
}
});
it('SRV-ROADTRIP-021: the day list is read back, not assembled from the write', async () => {
// A reanchor rewrites the whole set and a batch may clear legs before filling them,
// so what the write returned is not what the day now holds.
const s = svc({ reanchor: vi.fn().mockReturnValue([]) } as Partial<RoadtripService>);
await new RoadtripController(s).reanchor('7', '4', { vias: [] }, 'sock');
expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripVia:changed', { dayId: '4', vias: [VIA] }, 'sock');
});
it('SRV-ROADTRIP-022: laying a track down says which track the day now follows', async () => {
const track = { day_id: 4, place_id: 3, name: 'B96' };
const s = svc({ tracksForTrip: vi.fn().mockReturnValue([track]) } as Partial<RoadtripService>);
await new RoadtripController(s).createMany('7', '4', { vias: [], track: { place_id: 3 } }, 'sock');
expect(s.broadcast).toHaveBeenCalledWith('7', 'roadtripTrack:changed', { dayId: '4', track }, 'sock');
});
it('SRV-ROADTRIP-023: a refused write announces nothing', async () => {
const s = svc({ remove: vi.fn().mockReturnValue(false) } as Partial<RoadtripService>);
await expect(new RoadtripController(s).remove('7', '4', '9', 'sock')).rejects.toThrow();
expect(s.broadcast).not.toHaveBeenCalled();
});
});
});