mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
96ab748f34
The fill level was one figure for the whole trip, and a trip does not have one. A motorway rapid charger is worth about 80 % because the last fifth costs as long again; the charger at the hotel is worth all of it because the car stands there all night. Between those two the difference is a whole leg, and a single setting has to be wrong about one of them. So the badge on a refuelling stop is now the way in: it shows what that stop will actually fill to, its own figure or the traveller's, and opens a panel of the six answers people actually give. Under them it says what the choice buys in kilometres, because 80 % is an abstraction and "about 290 km from here" is the thing being decided. Picking the figure a stop already has hands it back to the setting, the same gesture the kind picker uses to un-pick a kind. The value lives in places.fill_percent, beside stop_type, because it is a property of the stop rather than of the person. Null means "follow the setting", which is every stop that predates the column and every stop nobody has an opinion about, so nothing changes for a trip that never touches it. Two pieces of shared machinery came out of it rather than being copied. The popover chrome — measure before showing, flip when the window is short, close on an outside click, Escape or a scroll — is one component now, used by the kind picker and this one. So is the assembly of the range from seven settings, which the routes hook and the rail both need and which would have drifted the first time a field was added.
274 lines
13 KiB
TypeScript
274 lines
13 KiB
TypeScript
/**
|
|
* 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, 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() }));
|
|
|
|
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<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);
|
|
|
|
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<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 });
|
|
});
|
|
});
|
|
});
|