mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
242 lines
12 KiB
TypeScript
242 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 { 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),
|
|
// 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();
|
|
});
|
|
});
|
|
});
|