import { RoadtripSearchService } from '../../src/nest/roadtrip/roadtrip-search.service'; import { GoogleRouteService } from '../../src/nest/roadtrip/google-route.service'; import { ChargingService } from '../../src/nest/roadtrip/charging.service'; /** * 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 { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { DatabaseModule } from '../../src/nest/database/database.module'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { RoadtripModule } from '../../src/nest/roadtrip/roadtrip.module'; import { RoadtripHazardsService } from '../../src/nest/roadtrip/roadtrip-hazards.service'; import { seedUser, sessionCookie } from './harness'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import request from 'supertest'; import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest'; 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 roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(trip_id, key));', ); 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, fill_percent INTEGER);`); 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() })); 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); it('gates charging data by addon and trip membership', async () => { const read = vi.spyOn(app.get(ChargingService), 'read').mockResolvedValue({} as never); try { await request(server).get('/api/trips/5/roadtrip/charging/10').expect(401); await request(server).get('/api/trips/6/roadtrip/charging/10').set('Cookie', cookie()).expect(404); await request(server).get('/api/trips/5/roadtrip/charging/10').set('Cookie', cookie()).expect(200); expect(read).toHaveBeenCalledWith(5, 10); setAddon(false); await request(server).get('/api/trips/5/roadtrip/charging/10').set('Cookie', cookie()).expect(404); expect(read).toHaveBeenCalledTimes(1); } finally { read.mockRestore(); } }); it('gates the coordinate lookup like the saved stop and validates before fetching', async () => { const lookup = vi.spyOn(app.get(ChargingService), 'lookup').mockResolvedValue({} as never); const body = { lat: 48.137, lng: 11.575, name: 'Ladepark Nord' }; const post = () => request(server).post('/api/trips/5/roadtrip/charging-lookup').set('Cookie', cookie()); try { await request(server).post('/api/trips/5/roadtrip/charging-lookup').send(body).expect(401); await request(server).post('/api/trips/6/roadtrip/charging-lookup').set('Cookie', cookie()).send(body).expect(404); await post().send({ ...body, lat: 91 }).expect(400); // A list is the shape a caller would reach for to fan a whole corridor out in one // request. There is no rate limiter here and each miss costs the upstream registry // several requests, so the contract refuses it before anything leaves the server. await post().send([body]).expect(400); expect(lookup).not.toHaveBeenCalled(); await post().send(body).expect(200); expect(lookup).toHaveBeenCalledWith(48.137, 11.575, 'Ladepark Nord'); setAddon(false); await post().send(body).expect(404); expect(lookup).toHaveBeenCalledTimes(1); } finally { lookup.mockRestore(); } }); it('validates Google route preview and import before executing either service', async () => { const routes = app.get(GoogleRouteService); const preview = vi.spyOn(routes, 'preview').mockResolvedValue({ stops: [] }); const save = vi.spyOn(routes, 'import').mockReturnValue({ imported: 2 }); const input = { dayId: 3, stops: [{ name: 'A', lat: 48, lng: 11 }, { name: 'B', lat: 41, lng: 12 }] }; try { await request(server).post('/api/roadtrip/google-maps-preview').send({ url: 'https://google.com/maps/dir/A/B' }).expect(401); await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'bad' }).expect(400); await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'https://google.com/maps/dir/A/B' }).expect(200); await request(server).post('/api/trips/6/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(404); await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send({ ...input, stops: [{ name: 'A', lat: 999, lng: 0 }] }).expect(400); await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(200); expect(save).toHaveBeenCalledWith(5, 1, input, undefined); setAddon(false); await request(server).post('/api/trips/5/roadtrip/google-maps-import').set('Cookie', cookie()).send(input).expect(404); await request(server).post('/api/roadtrip/google-maps-preview').set('Cookie', cookie()).send({ url: 'https://google.com/maps/dir/A/B' }).expect(404); expect(save).toHaveBeenCalledTimes(1); expect(preview).toHaveBeenCalledTimes(1); } finally { preview.mockRestore(); save.mockRestore(); } }); it('validates and gates area search before calling plugins', async () => { const search = vi.spyOn(app.get(RoadtripSearchService), 'search').mockResolvedValue({ pois: [], sources: [], failedSources: [], truncated: false, clamped: false }); const input = { categories: ['fuel'], bbox: { south: 48, north: 49, west: 10, east: 11 } }; try { await request(server).post('/api/roadtrip/search-area').send(input).expect(401); await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send({ ...input, categories: ['invalid'] }).expect(400); await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send(input).expect(200); expect(search).toHaveBeenCalledWith(input, 1); setAddon(false); await request(server).post('/api/roadtrip/search-area').set('Cookie', cookie()).send(input).expect(404); expect(search).toHaveBeenCalledTimes(1); } finally { search.mockRestore(); } }); it('gates live hazards by addon, authentication and trip access', async () => { const read = vi.spyOn(app.get(RoadtripHazardsService), 'read').mockResolvedValue({ fetchedAt: new Date().toISOString(), hazards: [], sources: [] }); try { await request(server).get('/api/trips/5/roadtrip/hazards').expect(401); await request(server).get('/api/trips/6/roadtrip/hazards').set('Cookie', cookie()).expect(404); await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(200); setAddon(false); await request(server).get('/api/trips/5/roadtrip/hazards').set('Cookie', cookie()).expect(404); expect(read).toHaveBeenCalledTimes(1); } finally { read.mockRestore(); } }); describe('shared trip driving preferences', () => { it('shares values with members and isolates other trips', async () => { db.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (5, 2)').run(); try { await request(server) .put('/api/trips/5/roadtrip/preferences') .set('Cookie', cookie()) .send({ roadtrip_range_km: 120, roadtrip_day_start: '08:00', roadtrip_day_end: '18:00' }) .expect(200); const member = await request(server) .get('/api/trips/5/roadtrip/preferences') .set('Cookie', sessionCookie(2)) .expect(200); expect(member.body.preferences.roadtrip_range_km).toBe(120); await request(server) .put('/api/trips/5/roadtrip/preferences') .set('Cookie', sessionCookie(2)) .send({ roadtrip_range_km: 160 }) .expect(200); const owner = await request(server) .get('/api/trips/5/roadtrip/preferences') .set('Cookie', cookie()) .expect(200); expect(owner.body.preferences.roadtrip_range_km).toBe(160); const other = await request(server) .get('/api/trips/6/roadtrip/preferences') .set('Cookie', sessionCookie(2)) .expect(200); expect(other.body.preferences).toEqual({}); checkPermission.mockReturnValue(false); await request(server) .put('/api/trips/5/roadtrip/preferences') .set('Cookie', sessionCookie(2)) .send({ roadtrip_range_km: 999 }) .expect(403); expect( db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_range_km'").get(), ).toEqual({ value: '160' }); } finally { db.prepare('DELETE FROM trip_members WHERE trip_id = 5 AND user_id = 2').run(); } }); it('ROADTRIP-E2E-013: the stay switch is off until it is set, and round-trips as a boolean', async () => { db.prepare("DELETE FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").run(); const before = await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', cookie()).expect(200); expect(before.body.preferences).not.toHaveProperty('roadtrip_hotel_bookends'); const saved = await request(server) .put('/api/trips/5/roadtrip/preferences') .set('Cookie', cookie()) .send({ roadtrip_hotel_bookends: true }) .expect(200); expect(saved.body.preferences.roadtrip_hotel_bookends).toBe(true); const after = await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', cookie()).expect(200); expect(after.body.preferences.roadtrip_hotel_bookends).toBe(true); await request(server) .put('/api/trips/5/roadtrip/preferences') .set('Cookie', cookie()) .send({ roadtrip_hotel_bookends: false }) .expect(200); expect( db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").get(), ).toEqual({ value: 'false' }); }); it('ROADTRIP-E2E-014: a stay switch that is not a boolean is a 400, not a stored row', async () => { db.prepare("DELETE FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").run(); for (const value of ['true', 1, null]) { await request(server) .put('/api/trips/5/roadtrip/preferences') .set('Cookie', cookie()) .send({ roadtrip_hotel_bookends: value }) .expect(400); } expect( db.prepare("SELECT value FROM roadtrip_preferences WHERE trip_id = 5 AND key = 'roadtrip_hotel_bookends'").get(), ).toBeUndefined(); }); it('refuses strangers and invalid daily windows', async () => { await request(server).get('/api/trips/5/roadtrip/preferences').set('Cookie', sessionCookie(2)).expect(404); await request(server) .put('/api/trips/5/roadtrip/preferences') .set('Cookie', cookie()) .send({ roadtrip_day_start: '18:00', roadtrip_day_end: '08:00' }) .expect(400); }); }); 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/preferences'], ['put', '/api/trips/5/roadtrip/preferences', { roadtrip_range_km: 120 }], ['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 }); }); }); });