Files
TREK/server/tests/unit/nest/roadtrip.controller.test.ts
T
2026-10-06 21:50:59 +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/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();
});
});
});