diff --git a/client/src/components/Roadtrip/alternativeOverlays.test.ts b/client/src/components/Roadtrip/alternativeOverlays.test.ts new file mode 100644 index 000000000..7cc996f28 --- /dev/null +++ b/client/src/components/Roadtrip/alternativeOverlays.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest' +import { buildAlternativeOverlays } from './alternativeOverlays' +import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors' +import type { RouteAlternative } from '../Map/RouteCalculator' + +/** + * FE-ALTOVL-001..010 — turning the router's answers into something drawable. + * + * Two decisions live here and neither is cosmetic: which of the offered roads is + * drawn as the one you are on, and where each label hangs. A label anchored on a + * stretch two routes share appears pinned to the wrong road, which is the whole + * reason this is not simply the midpoint. + */ + +const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' } + +/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */ +function line(n: number, lat: number): [number, number][] { + return Array.from({ length: n }, (_, i) => [lat, i / 10] as [number, number]) +} + +function alt(over: Partial = {}): RouteAlternative & { current?: boolean } { + return { + coordinates: line(20, 0), + duration: 3600, + distance: 100_000, + ...over, + } as RouteAlternative & { current?: boolean } +} + +describe('buildAlternativeOverlays', () => { + it('FE-ALTOVL-001: nothing to choose between is not a choice', () => { + // One answer drawn as an "alternative" would just double the route already + // on the map. + expect(buildAlternativeOverlays(undefined, LABELS)).toEqual([]) + expect(buildAlternativeOverlays([], LABELS)).toEqual([]) + expect(buildAlternativeOverlays([alt()], LABELS)).toEqual([]) + }) + + it('FE-ALTOVL-002: the quickest is the blue one when no road is being driven yet', () => { + const out = buildAlternativeOverlays( + [alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })], + LABELS, + ) + expect(out[1].color).toBe(ALT_PRIMARY) + expect(out[1].labelBg).toBe(ALT_LABEL_PRIMARY_BG) + expect(out[0].color).toBe(ALT_SECONDARY) + expect(out[0].labelBg).toBe(ALT_LABEL_SECONDARY_BG) + }) + + it('FE-ALTOVL-003: the road already being driven takes the blue, even when it is slower', () => { + // Blue means "the road you are on". Handing it to the quickest offer instead + // would recolour the drive the traveller deliberately bent. + const out = buildAlternativeOverlays( + [alt({ duration: 7200, current: true, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })], + LABELS, + ) + expect(out[0].color).toBe(ALT_PRIMARY) + expect(out[0].note).toBe('Current') + expect(out[1].color).toBe(ALT_SECONDARY) + }) + + it('FE-ALTOVL-004: what a road IS beats what it is not', () => { + // A way offered because the motorway was left out says so, rather than being + // labelled by how much slower it is — being off the motorway is the reason + // somebody would take it. + const out = buildAlternativeOverlays( + [ + alt({ duration: 3600, coordinates: line(20, 0) }), + alt({ duration: 5400, avoids: 'motorway', coordinates: line(20, 1) }), + alt({ duration: 5400, avoids: 'toll', coordinates: line(20, 2) }), + ], + LABELS, + ) + expect(out.map(o => o.note)).toEqual(['Fastest', 'No motorway', 'No tolls']) + }) + + it('FE-ALTOVL-005: a plain slower offer carries no note at all', () => { + const out = buildAlternativeOverlays( + [alt({ duration: 3600, coordinates: line(20, 0) }), alt({ duration: 5400, coordinates: line(20, 1) })], + LABELS, + ) + expect(out[1].note).toBe('') + }) + + it('FE-ALTOVL-006: how much slower is measured against the quickest, and never negative', () => { + const out = buildAlternativeOverlays( + [alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })], + LABELS, + ) + expect(out[0].slowerThanQuickest).toBe(1800) + expect(out[1].slowerThanQuickest).toBe(0) + expect(out.every(o => o.slowerThanQuickest >= 0)).toBe(true) + }) + + it('FE-ALTOVL-007: a label hangs where its own road is unlike every other', () => { + // Two roads that share both ends and part in the middle. The midpoint of the + // shared stretch would put both labels on the same line; the point furthest + // from the other route puts each on the stretch only it uses. + const shared = (lat: number): [number, number][] => [ + [0, 0], [0, 1], [lat, 2], [lat, 3], [0, 4], [0, 5], + ] + const out = buildAlternativeOverlays( + [alt({ coordinates: shared(0.5) }), alt({ coordinates: shared(-0.5) })], + LABELS, + ) + // Each label sits on its own detour, not on the common ends. + expect(Math.abs(out[0].at.lat)).toBeCloseTo(0.5, 5) + expect(Math.abs(out[1].at.lat)).toBeCloseTo(0.5, 5) + expect(out[0].at.lat).not.toBe(out[1].at.lat) + }) + + it('FE-ALTOVL-008: a route with no coordinates gets a label anchor rather than crashing', () => { + const out = buildAlternativeOverlays( + [alt({ coordinates: [] }), alt({ coordinates: line(20, 1) })], + LABELS, + ) + expect(out).toHaveLength(2) + expect(out[0].at).toEqual({ lat: 0, lng: 0 }) + }) + + it('FE-ALTOVL-009: the drive time is carried through as text and as seconds', () => { + const out = buildAlternativeOverlays( + [alt({ duration: 3600, distance: 42_000 }), alt({ duration: 5400, coordinates: line(20, 1) })], + LABELS, + ) + expect(out[0].duration).toBe(3600) + expect(out[0].distance).toBe(42_000) + // The label is the readable form of the same number. + expect(out[0].label).toBeTruthy() + expect(out[0].index).toBe(0) + expect(out[1].index).toBe(1) + }) + + it('FE-ALTOVL-010: only one road is ever the blue one', () => { + const out = buildAlternativeOverlays( + [ + alt({ duration: 3600, coordinates: line(20, 0) }), + alt({ duration: 3600, coordinates: line(20, 1) }), + alt({ duration: 3600, coordinates: line(20, 2) }), + ], + LABELS, + ) + expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1) + }) +}) diff --git a/client/src/components/Roadtrip/serviceMarker.test.ts b/client/src/components/Roadtrip/serviceMarker.test.ts new file mode 100644 index 000000000..5df2ae260 --- /dev/null +++ b/client/src/components/Roadtrip/serviceMarker.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from 'vitest' +import { serviceMarkerHtml, serviceMarkerOuter, SERVICE_MARKER_SIZE, SERVICE_MARKER_SIZE_SELECTED } from './serviceMarker' +import { serviceColor } from './roadtripModel' + +/** + * FE-SVCMARK-001..007 — the disc a service stop gets on the map. + * + * Both renderers need the same string: one hands it to a Leaflet divIcon, the + * other to an element it attaches itself. What is worth pinning is that an + * ordinary place gets nothing at all — a petrol station is something the drive + * passes through, and giving it a place-sized marker made a day look like it had + * twice the stops it has — and that the anchor arithmetic includes the border, + * or the pin sits a couple of pixels off the coordinate it marks. + */ + +describe('serviceMarkerHtml', () => { + it('FE-SVCMARK-001: an ordinary place gets no service marker', () => { + // Null is the signal to fall back to the place marker, so this is what keeps + // a destination from being drawn as a fuel stop. + expect(serviceMarkerHtml(null, false)).toBeNull() + expect(serviceMarkerHtml(undefined, false)).toBeNull() + expect(serviceMarkerHtml('', false)).toBeNull() + }) + + it('FE-SVCMARK-002: each kind of stop gets a disc in its own colour', () => { + // All six, not only the four the corridor picker offers: a meal and a + // viewpoint are pauses on the way too, and the map draws them the same way. + for (const kind of ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']) { + const html = serviceMarkerHtml(kind, false) + expect(html, kind).toBeTruthy() + expect(html, kind).toContain(serviceColor(kind)) + expect(html, kind).toContain('border-radius:50%') + } + }) + + it('FE-SVCMARK-003: an unknown service kind is still not drawn as one', () => { + // isServiceStopType is the gate; a value the picker never offers must not + // reach the renderer and produce a disc in an undefined colour. + expect(serviceMarkerHtml('banana', false)).toBeNull() + }) + + it('FE-SVCMARK-004: selecting one grows it, the way a place marker confirms a click', () => { + const plain = serviceMarkerHtml('fuel', false)! + const selected = serviceMarkerHtml('fuel', true)! + + expect(plain).toContain(`width:${SERVICE_MARKER_SIZE}px`) + expect(selected).toContain(`width:${SERVICE_MARKER_SIZE_SELECTED}px`) + expect(SERVICE_MARKER_SIZE_SELECTED).toBeGreaterThan(SERVICE_MARKER_SIZE) + }) + + it('FE-SVCMARK-005: selected carries the heavier ring, unselected the plain shadow', () => { + expect(serviceMarkerHtml('fuel', true)!).toContain('0 0 0 3px') + expect(serviceMarkerHtml('fuel', false)!).not.toContain('0 0 0 3px') + }) + + it('FE-SVCMARK-006: the icon is drawn white and inline, never a logo or a photo', () => { + // A fuel stop used to come back from the place search carrying the + // operator's logo, so the map showed a brand roundel where every other + // road-trip surface showed a pump. + const html = serviceMarkerHtml('fuel', false)! + expect(html).toContain(' { + it('FE-SVCMARK-007: the box an anchor centres on includes the border', () => { + // content-box plus a 2px border on each side. Measuring the inner size would + // leave every service pin two pixels off the coordinate it marks. + expect(serviceMarkerOuter(false)).toBe(SERVICE_MARKER_SIZE + 4) + expect(serviceMarkerOuter(true)).toBe(SERVICE_MARKER_SIZE_SELECTED + 4) + }) +}) diff --git a/server/tests/e2e/roadtrip.e2e.test.ts b/server/tests/e2e/roadtrip.e2e.test.ts new file mode 100644 index 000000000..d9351eb7c --- /dev/null +++ b/server/tests/e2e/roadtrip.e2e.test.ts @@ -0,0 +1,273 @@ +/** + * Road trip module e2e — the real guard chain against a temp SQLite db. + * + * The unit test next door pins the handler bodies. What only a booted container + * can show is the thing the controller's own comment calls load-bearing: that + * `@RequireAddon` does nothing on its own, so an instance with the addon + * switched off has to answer 404 here, and has to do it before asking who is + * calling. Delete AddonGuard from the chain and every case in the first block + * below turns red. + * + * The addon ships disabled, so "off" is the state an existing install upgrades + * into. That is what makes it worth a test rather than a comment. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { DatabaseModule } from '../../src/nest/database/database.module'; +import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; +import { seedUser, sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec('PRAGMA foreign_keys = ON'); + tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0, + avatar TEXT);`); + tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, title TEXT, end_date TEXT);'); + tmp.exec('CREATE TABLE trip_members (trip_id INTEGER NOT NULL, user_id INTEGER NOT NULL);'); + tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, + day_number INTEGER, date TEXT, title TEXT, notes TEXT);`); + tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, + lat REAL, lng REAL, route_geometry TEXT, stop_type TEXT);`); + tmp.exec(`CREATE TABLE roadtrip_vias (id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + after_order_index INTEGER NOT NULL, sequence INTEGER NOT NULL DEFAULT 0, + lat REAL NOT NULL, lng REAL NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP);`); + tmp.exec(`CREATE TABLE roadtrip_day_tracks ( + day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, stray_km REAL);`); + // AddonsService reads this; StorageRegistryService reads app_settings at init. + tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 0, + name TEXT, description TEXT, category TEXT, sort_order INTEGER DEFAULT 0);`); + tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ + db, + canAccessTrip: (tripId: number | string, userId: number) => + db.prepare(` + SELECT t.id, t.user_id FROM trips t + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + `).get(userId, tripId, userId), + isOwner: () => false, + getPlaceWithTags: () => null, + closeDb: () => {}, + reinitialize: () => {}, +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); + +import { PermissionsService } from '../../src/nest/permissions/permissions.service'; +import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; +import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; + +const ADDON_ID = 'roadtrip'; + +function setAddon(enabled: boolean): void { + db.prepare('INSERT INTO addons (id, enabled) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET enabled = excluded.enabled') + .run(ADDON_ID, enabled ? 1 : 0); +} + +describe('Roadtrip e2e (real guard chain + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + let checkPermission: MockInstance; + + async function build() { + const moduleRef = await Test.createTestingModule({ + imports: [DatabaseModule, RealtimeModule, RoadtripModule], + }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalPipes(new ZodValidationPipe()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + seedUser(db as never, { id: 1 }); + seedUser(db as never, { id: 2, email: 'other@example.test' }); + db.prepare('INSERT INTO trips (id, user_id, title) VALUES (5, 1, ?)').run('Norway'); + db.prepare('INSERT INTO trips (id, user_id, title) VALUES (6, 2, ?)').run('Somebody else'); + db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1)').run(); + db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (4, 6, 1)').run(); + // A track (a place carrying a route geometry) on each trip. + db.prepare("INSERT INTO places (id, trip_id, name, route_geometry) VALUES (10, 5, 'Scenic', '[[1,2]]')").run(); + db.prepare("INSERT INTO places (id, trip_id, name, route_geometry) VALUES (11, 6, 'Theirs', '[[1,2]]')").run(); + // An ordinary place, which is not a track. + db.prepare("INSERT INTO places (id, trip_id, name) VALUES (12, 5, 'Just a stop')").run(); + app = await build(); + checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); + server = app.getHttpServer(); + }); + + beforeEach(() => { + db.prepare('DELETE FROM roadtrip_vias').run(); + db.prepare('DELETE FROM roadtrip_day_tracks').run(); + checkPermission.mockReturnValue(true); + setAddon(true); + }); + + afterAll(async () => { await app?.close(); }); + + const cookie = () => sessionCookie(1); + + describe('the addon gate', () => { + // Six routes, all of them. `@RequireAddon` is metadata and inert without the + // guard, and the guard is not global — so this block is the only thing that + // fails if somebody drops AddonGuard from the chain again. + const routes: [string, string, unknown?][] = [ + ['get', '/api/trips/5/roadtrip/vias'], + ['get', '/api/trips/5/roadtrip/days/3/vias'], + ['post', '/api/trips/5/roadtrip/days/3/vias', { after_order_index: 0, lat: 53, lng: 10 }], + ['post', '/api/trips/5/roadtrip/days/3/vias/batch', { vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }], + ['put', '/api/trips/5/roadtrip/days/3/vias', { vias: [] }], + ['delete', '/api/trips/5/roadtrip/days/3/vias/1'], + ]; + + it('ROADTRIP-E2E-001: answers 404 on every route while the addon is off', async () => { + setAddon(false); + for (const [method, url, body] of routes) { + const req = (request(server) as never as Record request.Test>)[method](url) + .set('Cookie', cookie()); + const res = await (body ? req.send(body as object) : req); + expect(res.status, `${method.toUpperCase()} ${url}`).toBe(404); + } + }); + + it('ROADTRIP-E2E-002: a disabled addon owes an anonymous caller a 404, not a 401', async () => { + // The gate leads the chain for this reason: a 401 tells a stranger the + // route exists. Both answers refuse; only one of them says nothing. + setAddon(false); + const res = await request(server).get('/api/trips/5/roadtrip/vias'); + expect(res.status).toBe(404); + }); + + it('ROADTRIP-E2E-003: with the addon on, an anonymous caller gets 401', async () => { + const res = await request(server).get('/api/trips/5/roadtrip/vias'); + expect(res.status).toBe(401); + }); + }); + + describe('trip scoping', () => { + it('ROADTRIP-E2E-004: a trip the caller cannot reach is 404', async () => { + const res = await request(server).get('/api/trips/6/roadtrip/vias').set('Cookie', cookie()); + expect(res.status).toBe(404); + }); + + it('ROADTRIP-E2E-005: a day from another trip is not reachable through one the caller has', async () => { + // Day 4 exists and is real; it just belongs to trip 6. The guard proves + // the trip, `requireDay` proves the day is part of it. + const res = await request(server).get('/api/trips/5/roadtrip/days/4/vias').set('Cookie', cookie()); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Day not found' }); + }); + + it('ROADTRIP-E2E-006: a track from another trip cannot label this day', async () => { + const res = await request(server) + .post('/api/trips/5/roadtrip/days/3/vias/batch') + .set('Cookie', cookie()) + .send({ vias: [{ after_order_index: 0, lat: 53, lng: 10 }], track: { place_id: 11 } }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Track not found' }); + }); + + it('ROADTRIP-E2E-007: a place without a route geometry is not a track', async () => { + const res = await request(server) + .post('/api/trips/5/roadtrip/days/3/vias/batch') + .set('Cookie', cookie()) + .send({ vias: [{ after_order_index: 0, lat: 53, lng: 10 }], track: { place_id: 12 } }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Track not found' }); + }); + }); + + describe('permission', () => { + it('ROADTRIP-E2E-008: reading needs no day_edit, writing does', async () => { + checkPermission.mockReturnValue(false); + expect((await request(server).get('/api/trips/5/roadtrip/vias').set('Cookie', cookie())).status).toBe(200); + const write = await request(server) + .post('/api/trips/5/roadtrip/days/3/vias') + .set('Cookie', cookie()) + .send({ after_order_index: 0, lat: 53, lng: 10 }); + expect(write.status).toBe(403); + }); + }); + + describe('the wire contract', () => { + it('ROADTRIP-E2E-009: a via round-trips through create, list, move and delete', async () => { + const created = await request(server) + .post('/api/trips/5/roadtrip/days/3/vias') + .set('Cookie', cookie()) + .send({ after_order_index: 0, lat: 53, lng: 10 }); + expect(created.status).toBe(201); + const id = created.body.via.id; + expect(created.body.via).toMatchObject({ day_id: 3, after_order_index: 0, sequence: 0, lat: 53, lng: 10 }); + + const listed = await request(server).get('/api/trips/5/roadtrip/days/3/vias').set('Cookie', cookie()); + expect(listed.body.vias.map((v: { id: number }) => v.id)).toEqual([id]); + + const moved = await request(server) + .put(`/api/trips/5/roadtrip/days/3/vias/${id}`) + .set('Cookie', cookie()) + .send({ lat: 54, lng: 11 }); + // 200, not the 201 Nest defaults a body-carrying write to. + expect(moved.status).toBe(200); + expect(moved.body.via).toMatchObject({ lat: 54, lng: 11 }); + + expect((await request(server).delete(`/api/trips/5/roadtrip/days/3/vias/${id}`).set('Cookie', cookie())).status) + .toBe(200); + const after = await request(server).get('/api/trips/5/roadtrip/days/3/vias').set('Cookie', cookie()); + expect(after.body.vias).toEqual([]); + }); + + it('ROADTRIP-E2E-010: the batch route answers 200 and records the track it was fitted to', async () => { + const res = await request(server) + .post('/api/trips/5/roadtrip/days/3/vias/batch') + .set('Cookie', cookie()) + .send({ + vias: [{ after_order_index: 0, lat: 53, lng: 10 }, { after_order_index: 0, lat: 53.5, lng: 10.5 }], + track: { place_id: 10, stray_km: 1.5 }, + }); + expect(res.status).toBe(200); + expect(res.body.vias).toHaveLength(2); + + const all = await request(server).get('/api/trips/5/roadtrip/vias').set('Cookie', cookie()); + expect(all.body.tracks).toEqual([{ day_id: 3, place_id: 10, stray_km: 1.5 }]); + }); + + it('ROADTRIP-E2E-011: the re-anchor route is declared before the id route', async () => { + // `PUT days/:dayId/vias` and `PUT days/:dayId/vias/:id` differ by one + // segment. Declared the other way round, the batch would be swallowed by + // the id route and every re-anchoring would 404 on a via named "vias". + const created = await request(server) + .post('/api/trips/5/roadtrip/days/3/vias') + .set('Cookie', cookie()) + .send({ after_order_index: 2, lat: 53, lng: 10 }); + const res = await request(server) + .put('/api/trips/5/roadtrip/days/3/vias') + .set('Cookie', cookie()) + .send({ vias: [{ id: created.body.via.id, after_order_index: 1 }] }); + expect(res.status).toBe(200); + expect(res.body.vias[0]).toMatchObject({ after_order_index: 1 }); + }); + + it('ROADTRIP-E2E-012: a body the contract refuses is a 400, not a stored row', async () => { + const res = await request(server) + .post('/api/trips/5/roadtrip/days/3/vias') + .set('Cookie', cookie()) + .send({ after_order_index: 0, lat: 999, lng: 10 }); + expect(res.status).toBe(400); + expect(db.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 }); + }); + }); +}); diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index 31253c46b..f6245754b 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -34,6 +34,11 @@ const RESET_TABLES = [ 'collab_messages', 'collab_polls', 'collab_notes', + // Road trip (#1797). Both hang off days, so they go before it like the rest + // of the day content; a new domain's tables belong here or its rows leak from + // one case into the next. + 'roadtrip_day_tracks', + 'roadtrip_vias', // Day content 'day_notes', 'todo_category_assignees', diff --git a/server/tests/unit/mcp/tools-roadtrip.test.ts b/server/tests/unit/mcp/tools-roadtrip.test.ts new file mode 100644 index 000000000..0c8ed0dbf --- /dev/null +++ b/server/tests/unit/mcp/tools-roadtrip.test.ts @@ -0,0 +1,329 @@ +/** + * The five road-trip MCP tools. + * + * They had no test at all, and the shared harness seeds the addon disabled, so + * `when: roadtripAddonOn` kept them from ever attaching: the handler bodies had + * never run. What matters here is the same thing the REST e2e pins, because the + * repo requires the two surfaces to move together: the addon gate, the + * day-belongs-to-this-trip check, the `day_edit` permission, and the cross-trip + * track check on the batch tool. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: unknown, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: unknown, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb, setAddonEnabled } from '../../helpers/test-db'; +import { createUser, createTrip, createDay, createPlace } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import { ADDON_IDS } from '../../../src/addons'; +import { addTripMember } from '../../helpers/factories'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo'; + +// The permissions cache is module-scoped, so a write through any instance is +// what the tool's own check reads back. +const savePermissions = new PermissionsService(new DatabaseService(testDb)).savePermissions.bind( + new PermissionsService(new DatabaseService(testDb)), +); + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + // resetTestDb leaves the addons table alone, so each run restates it. The + // addon ships disabled, which is why the tools were invisible before. + setAddonEnabled(testDb, ADDON_IDS.ROADTRIP, true); +}); + +afterAll(() => { testDb.close(); }); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false, scopes: null }); + try { await fn(h); } finally { await h.cleanup(); } +} + +/** A trip with one day, plus a place carrying a route geometry to use as a track. */ +function scenario() { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Norway' }); + const day = createDay(testDb, trip.id, { day_number: 1 }); + const track = createPlace(testDb, trip.id, { name: 'Scenic' }); + testDb.prepare("UPDATE places SET route_geometry = '[[1,2],[3,4]]' WHERE id = ?").run(track.id); + return { user, trip, day, track }; +} + +describe('road-trip MCP tools', () => { + it('MCP-ROADTRIP-001: the five tools are gone while the addon is off', async () => { + const { user } = createUser(testDb); + setAddonEnabled(testDb, ADDON_IDS.ROADTRIP, false); + + await withHarness(user.id, async (h) => { + const names = (await h.client.listTools()).tools.map(t => t.name); + for (const tool of ['list_route_vias', 'add_route_via', 'add_route_vias', 'reanchor_route_vias', 'remove_route_via']) { + expect(names, tool).not.toContain(tool); + } + }); + }); + + it('MCP-ROADTRIP-002: and there while it is on', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const names = (await h.client.listTools()).tools.map(t => t.name); + for (const tool of ['list_route_vias', 'add_route_via', 'add_route_vias', 'reanchor_route_vias', 'remove_route_via']) { + expect(names, tool).toContain(tool); + } + }); + }); + + it('MCP-ROADTRIP-003: adding a via, then reading it back for the day and for the trip', async () => { + const { user, trip, day } = scenario(); + + await withHarness(user.id, async (h) => { + const added = await h.client.callTool({ + name: 'add_route_via', + arguments: { tripId: trip.id, dayId: day.id, after_order_index: 0, lat: 53, lng: 10 }, + }); + expect(added.isError).toBeFalsy(); + expect((parseToolResult(added) as { via: { day_id: number } }).via).toMatchObject({ day_id: day.id, lat: 53 }); + + const forDay = parseToolResult(await h.client.callTool({ + name: 'list_route_vias', + arguments: { tripId: trip.id, dayId: day.id }, + })) as { vias: unknown[] }; + expect(forDay.vias).toHaveLength(1); + + // Without a day, the answer covers the whole trip and carries the tracks + // alongside, which is the shape the client reads on every load. + const forTrip = parseToolResult(await h.client.callTool({ + name: 'list_route_vias', + arguments: { tripId: trip.id }, + })) as { vias: unknown[]; tracks: unknown[] }; + expect(forTrip.vias).toHaveLength(1); + expect(forTrip.tracks).toEqual([]); + }); + }); + + it('MCP-ROADTRIP-004: a day from another trip is refused on every tool', async () => { + // The same check the REST routes make, which is what the repo means by the + // two surfaces moving together. + const { user, trip } = scenario(); + const other = createTrip(testDb, user.id, { title: 'Elsewhere' }); + const otherDay = createDay(testDb, other.id, { day_number: 1 }); + + await withHarness(user.id, async (h) => { + const calls: [string, Record][] = [ + ['list_route_vias', { tripId: trip.id, dayId: otherDay.id }], + ['add_route_via', { tripId: trip.id, dayId: otherDay.id, after_order_index: 0, lat: 53, lng: 10 }], + ['add_route_vias', { tripId: trip.id, dayId: otherDay.id, vias: [{ after_order_index: 0, lat: 53, lng: 10 }] }], + ['reanchor_route_vias', { tripId: trip.id, dayId: otherDay.id, vias: [] }], + ['remove_route_via', { tripId: trip.id, dayId: otherDay.id, viaId: 1 }], + ]; + for (const [name, args] of calls) { + const res = await h.client.callTool({ name, arguments: args }); + expect(res.isError, name).toBeTruthy(); + } + expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 }); + }); + }); + + it('MCP-ROADTRIP-005: a trip the caller cannot reach is refused', async () => { + const { user } = createUser(testDb); + const stranger = createUser(testDb, { email: 'other@example.test' }); + const theirs = createTrip(testDb, stranger.user.id, { title: 'Theirs' }); + const theirDay = createDay(testDb, theirs.id, { day_number: 1 }); + + await withHarness(user.id, async (h) => { + const res = await h.client.callTool({ + name: 'add_route_via', + arguments: { tripId: theirs.id, dayId: theirDay.id, after_order_index: 0, lat: 53, lng: 10 }, + }); + expect(res.isError).toBeTruthy(); + }); + }); + + it('MCP-ROADTRIP-006: a chain lands in one call and records its track', async () => { + const { user, trip, day, track } = scenario(); + + await withHarness(user.id, async (h) => { + const res = await h.client.callTool({ + name: 'add_route_vias', + arguments: { + tripId: trip.id, + dayId: day.id, + vias: [{ after_order_index: 0, lat: 53, lng: 10 }, { after_order_index: 0, lat: 53.5, lng: 10.5 }], + track: { place_id: track.id, stray_km: 1.5 }, + }, + }); + expect(res.isError).toBeFalsy(); + expect((parseToolResult(res) as { vias: unknown[] }).vias).toHaveLength(2); + + const all = parseToolResult(await h.client.callTool({ + name: 'list_route_vias', + arguments: { tripId: trip.id }, + })) as { tracks: { day_id: number; place_id: number; stray_km: number }[] }; + expect(all.tracks).toEqual([{ day_id: day.id, place_id: track.id, stray_km: 1.5 }]); + }); + }); + + it('MCP-ROADTRIP-007: a track from another trip cannot label this day', async () => { + const { user, trip, day } = scenario(); + const stranger = createUser(testDb, { email: 'other2@example.test' }); + const theirs = createTrip(testDb, stranger.user.id, { title: 'Theirs' }); + const theirTrack = createPlace(testDb, theirs.id, { name: 'Not yours' }); + testDb.prepare("UPDATE places SET route_geometry = '[[1,2]]' WHERE id = ?").run(theirTrack.id); + + await withHarness(user.id, async (h) => { + const res = await h.client.callTool({ + name: 'add_route_vias', + arguments: { + tripId: trip.id, + dayId: day.id, + vias: [{ after_order_index: 0, lat: 53, lng: 10 }], + track: { place_id: theirTrack.id }, + }, + }); + expect(res.isError).toBeTruthy(); + expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 }); + }); + }); + + it('MCP-ROADTRIP-008: re-anchoring moves what it names and removes what it lists', async () => { + const { user, trip, day } = scenario(); + + await withHarness(user.id, async (h) => { + const first = parseToolResult(await h.client.callTool({ + name: 'add_route_via', + arguments: { tripId: trip.id, dayId: day.id, after_order_index: 0, lat: 53, lng: 10 }, + })) as { via: { id: number } }; + const second = parseToolResult(await h.client.callTool({ + name: 'add_route_via', + arguments: { tripId: trip.id, dayId: day.id, after_order_index: 1, lat: 54, lng: 11 }, + })) as { via: { id: number } }; + + const res = parseToolResult(await h.client.callTool({ + name: 'reanchor_route_vias', + arguments: { + tripId: trip.id, + dayId: day.id, + vias: [{ id: second.via.id, after_order_index: 0 }], + remove: [first.via.id], + }, + })) as { vias: { id: number; after_order_index: number }[] }; + + expect(res.vias).toHaveLength(1); + expect(res.vias[0]).toMatchObject({ id: second.via.id, after_order_index: 0 }); + }); + }); + + it('MCP-ROADTRIP-010: a member without day_edit may read but not write', async () => { + // The permission the controller demands is the permission the tool demands: + // an assistant must never be able to do through a tool what the person it + // is acting for cannot do through the UI. + const { user, trip, day } = scenario(); + const member = createUser(testDb, { email: 'member@example.test' }); + addTripMember(testDb, trip.id, member.user.id); + savePermissions({ day_edit: 'trip_owner' }); + + try { + await withHarness(member.user.id, async (h) => { + const read = await h.client.callTool({ + name: 'list_route_vias', + arguments: { tripId: trip.id, dayId: day.id }, + }); + expect(read.isError).toBeFalsy(); + + for (const name of ['add_route_via', 'add_route_vias', 'reanchor_route_vias', 'remove_route_via']) { + const args: Record = { tripId: trip.id, dayId: day.id }; + if (name === 'add_route_via') Object.assign(args, { after_order_index: 0, lat: 53, lng: 10 }); + if (name === 'add_route_vias') args.vias = [{ after_order_index: 0, lat: 53, lng: 10 }]; + if (name === 'reanchor_route_vias') args.vias = []; + if (name === 'remove_route_via') args.viaId = 1; + const res = await h.client.callTool({ name, arguments: args }); + expect(res.isError, name).toBeTruthy(); + } + }); + expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 }); + } finally { + savePermissions({ day_edit: 'trip_member' }); + } + // The owner is unaffected. + await withHarness(user.id, async (h) => { + const res = await h.client.callTool({ + name: 'add_route_via', + arguments: { tripId: trip.id, dayId: day.id, after_order_index: 0, lat: 53, lng: 10 }, + }); + expect(res.isError).toBeFalsy(); + }); + }); + + it('MCP-ROADTRIP-011: the demo account may look but never write', async () => { + const { trip, day } = scenario(); + const demo = createUser(testDb, { email: DEMO_EMAIL_PRIMARY }); + addTripMember(testDb, trip.id, demo.user.id); + + process.env.DEMO_MODE = 'true'; + try { + await withHarness(demo.user.id, async (h) => { + const read = await h.client.callTool({ + name: 'list_route_vias', + arguments: { tripId: trip.id, dayId: day.id }, + }); + expect(read.isError).toBeFalsy(); + + const write = await h.client.callTool({ + name: 'add_route_via', + arguments: { tripId: trip.id, dayId: day.id, after_order_index: 0, lat: 53, lng: 10 }, + }); + expect(write.isError).toBeTruthy(); + expect((write.content as { text: string }[])[0].text).toContain('demo'); + }); + } finally { + delete process.env.DEMO_MODE; + } + expect(testDb.prepare('SELECT COUNT(*) c FROM roadtrip_vias').get()).toEqual({ c: 0 }); + }); + + it('MCP-ROADTRIP-009: removing one that is not on the day is refused, not silently ignored', async () => { + const { user, trip, day } = scenario(); + + await withHarness(user.id, async (h) => { + const res = await h.client.callTool({ + name: 'remove_route_via', + arguments: { tripId: trip.id, dayId: day.id, viaId: 9999 }, + }); + expect(res.isError).toBeTruthy(); + }); + }); +}); diff --git a/server/tests/unit/nest/roadtrip.controller.test.ts b/server/tests/unit/nest/roadtrip.controller.test.ts new file mode 100644 index 000000000..aaed155f5 --- /dev/null +++ b/server/tests/unit/nest/roadtrip.controller.test.ts @@ -0,0 +1,183 @@ +/** + * 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 { + 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' } }); + }); +}); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 16e5fe998..7032ec653 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -118,6 +118,14 @@ export default defineConfig({ 'src/nest/realtime/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 }, 'src/nest/reservation-import/**/*.ts': { statements: 61, branches: 55, functions: 41, lines: 61 }, 'src/nest/reservations/**/*.ts': { statements: 92, branches: 83, functions: 96, lines: 96 }, + // New domain in this change. Measured over its own suites at + // 94.0/81.1/100/99.1 and pinned under that: the subset run does not + // load the controller the way the full run does, so the real figure is + // not this one. Regenerate with scripts/coverage-thresholds.mjs after a + // full run. What matters now is that it carries its own floor instead of + // hiding in the 80 catch-all, where 557 new lines could shed twenty + // points against the headroom of the domains beside them. + 'src/nest/roadtrip/**/*.ts': { statements: 88, branches: 80, functions: 95, lines: 95 }, 'src/nest/settings/**/*.ts': { statements: 87, branches: 71, functions: 99, lines: 88 }, 'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 }, 'src/nest/storage/**/*.ts': { statements: 94, branches: 84, functions: 97, lines: 94 },