Files
TREK/server/tests/unit/nest/roadtrip.controller.test.ts
T
Maurice 65be8db122 test(roadtrip): cover the surface the domain shipped without
The domain arrived with 557 lines, a service test, and nothing else. No
controller test, no e2e, and no MCP test — so the guard chain, the trip scoping
on every route, the cross-trip track check and the day_edit parity were all
unasserted, and the whole domain sat in the 80 catch-all where it could shed
twenty points against the headroom of the domains beside it.

The e2e is the one that earns its place. `@RequireAddon` is metadata and does
nothing on its own, and AddonGuard is not global, so the ordering the
controller's own comment calls load-bearing is exactly the kind of thing a later
change removes without noticing — it had already happened once. Deleting the
guard now turns three cases red, which I checked by deleting it.

The MCP tools had never executed at all: the shared harness seeds the addon
disabled, so `when: roadtripAddonOn` kept all five from attaching. They now run
against the same checks the REST routes make, including the demo account and a
member without day_edit, because the repo requires the two surfaces to move
together.

Two client modules that had no test get one: the overlay builder, where two
decisions live that are not cosmetic — which offered road is drawn as the one you
are on, and that a label hangs where its own route is unlike every other, rather
than at a midpoint that can fall on a stretch two routes share — and the service
marker, where the thing worth pinning is that an ordinary place gets nothing
back, and that the anchor arithmetic includes the border.

The domain carries its own coverage floor now instead of hiding in the catch-all.
Pinned under the measured figure on purpose; the exact ratchet gets regenerated
with the repo's own script after a full run.

Along the way, roadtrip_vias and roadtrip_day_tracks join the reset list every
other domain's tables are on. Without that a via written by one case was still
there for the next one, which is how the first version of the MCP suite passed
an assertion it should have failed.
2026-09-07 18:36:54 +02:00

184 lines
8.6 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 { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { RoadtripController } from '../../../src/nest/roadtrip/roadtrip.controller';
import type { RoadtripService } from '../../../src/nest/roadtrip/roadtrip.service';
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';
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),
...o,
} as unknown as RoadtripService;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { 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', () => {
const s = svc();
expect(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', () => {
const s = svc();
expect(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', () => {
// 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(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', () => {
const s = svc();
expect(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', () => {
const s = svc({ dayExists: vi.fn().mockReturnValue(false) });
const c = new RoadtripController(s);
const notFound = { status: 404, body: { error: 'Day not found' } };
expect(thrown(() => c.create('7', '999', body))).toEqual(notFound);
expect(thrown(() => c.createMany('7', '999', { vias: [body] }))).toEqual(notFound);
expect(thrown(() => c.reanchor('7', '999', { vias: [] }))).toEqual(notFound);
expect(thrown(() => c.update('7', '999', '5', { lat: 1, lng: 2 }))).toEqual(notFound);
expect(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', () => {
const s = svc();
const batch = { vias: [body, body], replace_legs: [0] };
expect(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', () => {
// 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(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', () => {
const s = svc();
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', () => {
// `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(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', () => {
const s = svc();
const plan = { vias: [{ id: 5, after_order_index: 1 }], remove: [6] };
expect(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', () => {
const s = svc();
expect(new RoadtripController(s).update('7', '4', '5', { lat: 54, lng: 11 })).toEqual({ via: VIA });
expect(s.move).toHaveBeenCalledWith('5', '4', 54, 11);
});
it('ROADTRIP-CTL-013: a via that is not on this day is a 404, not a silent no-op', () => {
const s = svc({ move: vi.fn().mockReturnValue(null) });
expect(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', () => {
const s = svc();
expect(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', () => {
const s = svc({ remove: vi.fn().mockReturnValue(false) });
expect(thrown(() => new RoadtripController(s).remove('7', '4', '999')))
.toEqual({ status: 404, body: { error: 'Via not found' } });
});
});