mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
390 lines
19 KiB
TypeScript
390 lines
19 KiB
TypeScript
import { RoadtripSearchService } from '../../src/nest/roadtrip/roadtrip-search.service';
|
|
import { GoogleRouteService } from '../../src/nest/roadtrip/google-route.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<ReturnType<typeof build>>;
|
|
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('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('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<string, (u: string) => 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 });
|
|
});
|
|
});
|
|
});
|