mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
c956fe92ba
dev now runs on MikroORM, so the positional migrations, seeds.ts and DatabaseService the Tours server side was built on are gone. Behaviour is unchanged; persistence moves onto dev's architecture: - one MikroORM migration for tour_types, tours and tour_waypoints, idempotent so instances that ran the old positional steps keep their data; the hike tour type is seeded inside it, the addon row in the AddonSeeder - entities and repositories for the three tables, tours domain on repositories and UnitOfWork - tour facet joins in places, assignments and days re-applied on the repository code Fixes found while porting: - GPX import checks addon, trip access and permission in the handler instead of guards, so a refused upload gets a 403/404 instead of a connection reset - copying a trip copies its tours and waypoints - a tour cannot be moved onto a day that already has it - updateTour checks existence inside its transaction Adds tours e2e tests and rewrites the tours unit tests onto the migrated test database.
315 lines
19 KiB
TypeScript
315 lines
19 KiB
TypeScript
/**
|
|
* Days + day-notes module e2e — exercises both migrated mounts through the real
|
|
* JwtAuthGuard against a real migrated-and-seeded temp SQLite db
|
|
* (createSnapshotTestDb(), Plan 3c Task 2 — this used to hand-roll a dozen
|
|
* CREATE TABLEs, a second hand-maintained schema copy that omitted
|
|
* `roadtrip_day_tracks` and the rest of the entity graph `DaysRepository
|
|
* .listByTrip`'s ORM `find()` now needs to resolve at query time, the exact
|
|
* "e2e suites build their schema from hand-written partial DDL" risk the
|
|
* plan's inventory §15c flagged — `no such table: roadtrip_day_tracks` on
|
|
* this suite's very first `GET /api/trips/:id/days` was the failure that
|
|
* proved it). DaysService and DayNotesService now run through real
|
|
* repositories over the same migrated connection; trip access resolves
|
|
* through `TripsRepository.findAccessible` via the real request-scoped
|
|
* `EntityManager` `createTestMikroOrmModule` wires in, not a hand-rolled
|
|
* `canAccessTrip` mock (the legacy override this file used to export from
|
|
* `db/database.ts` — deleted there since Plan 3c Task 0b; a stale mock here
|
|
* would silently do nothing, not fail loudly, which is worse than removing
|
|
* it). Only the permission check and the WebSocket broadcast stay mocked.
|
|
*/
|
|
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 { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
|
import { Test } from '@nestjs/testing';
|
|
import { sessionCookie } from './harness';
|
|
|
|
vi.mock('../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
|
|
|
import { db } from '../../src/db/database';
|
|
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
|
|
|
// Since the permissions DI migration, the check is a spy on the container's
|
|
// PermissionsService singleton (created in beforeAll, after build()).
|
|
let checkPermission: MockInstance;
|
|
|
|
import { DaysModule } from '../../src/nest/days/days.module';
|
|
// The note routes nest under the days prefix but live in their own domain now;
|
|
// this container has to assemble both or /days/:dayId/notes 404s here while
|
|
// working in production.
|
|
import { DayNotesModule } from '../../src/nest/day-notes/day-notes.module';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { createTestMikroOrmModule } from '../helpers/test-orm';
|
|
|
|
describe('Days + day-notes e2e (real auth guard + temp SQLite, real day SQL)', () => {
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), RealtimeModule, DaysModule, DayNotesModule] }).compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// harness.ts's seedUser() omits password_hash, which the real migrated
|
|
// schema requires NOT NULL (addons.e2e.test.ts/share.e2e.test.ts hit the
|
|
// same thing) — a raw insert here instead, matching the SeededUser shape
|
|
// id/role/password_version=0 that sessionCookie(1) needs.
|
|
db.prepare(
|
|
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
|
|
).run();
|
|
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (5, 1, ?)').run('Trip');
|
|
db.prepare('INSERT INTO days (id, trip_id, day_number) VALUES (3, 5, 1)').run();
|
|
app = await build();
|
|
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
db.prepare('DELETE FROM day_notes').run();
|
|
db.prepare('DELETE FROM day_assignments').run();
|
|
checkPermission.mockReturnValue(true);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
});
|
|
|
|
it('401 without a cookie', async () => {
|
|
expect((await request(server).get('/api/trips/5/days')).status).toBe(401);
|
|
});
|
|
|
|
it('200 list days (the { days } envelope, real rows with assignments + notes_items)', async () => {
|
|
const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.days).toHaveLength(1);
|
|
expect(res.body.days[0]).toMatchObject({ id: 3, trip_id: 5, day_number: 1, assignments: [], notes_items: [] });
|
|
});
|
|
|
|
it('200 list projects Tour facets once per assignment and leaves other tracks ordinary', async () => {
|
|
const legacyGeometry = JSON.stringify([[48, 11, 600], [48.01, 11.02, 650]]);
|
|
const tourGeometry = JSON.stringify([[48.02, 11.03, 700], [48.03, 11.04, 750]]);
|
|
db.prepare('INSERT INTO places (id, trip_id, name, route_geometry) VALUES (2, 5, ?, ?), (3, 5, ?, ?), (4, 5, ?, ?)')
|
|
.run('Ordinary', null, 'Legacy track', legacyGeometry, 'Tour', tourGeometry);
|
|
db.prepare("INSERT INTO tours (place_id, tour_type) VALUES (4, 'hike')").run();
|
|
const assignmentIds = [2, 3, 4, 4].map((placeId, orderIndex) => Number(
|
|
db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (3, ?, ?)')
|
|
.run(placeId, orderIndex).lastInsertRowid,
|
|
));
|
|
|
|
const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1));
|
|
|
|
expect(res.status).toBe(200);
|
|
const assignments = res.body.days[0].assignments as Array<{
|
|
id: number;
|
|
order_index: number;
|
|
tour_place_id: number | null;
|
|
tour_route_geometry: string | null;
|
|
place: { id: number };
|
|
}>;
|
|
expect(assignments).toHaveLength(4);
|
|
expect(assignments.map(a => a.id)).toEqual(assignmentIds);
|
|
expect(assignments.map(a => a.order_index)).toEqual([0, 1, 2, 3]);
|
|
expect(assignments[0]).toMatchObject({ place: { id: 2 }, tour_place_id: null, tour_route_geometry: null });
|
|
expect(db.prepare('SELECT route_geometry FROM places WHERE id = 3').get()).toEqual({ route_geometry: legacyGeometry });
|
|
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 3').get()).toBeUndefined();
|
|
expect(db.prepare('SELECT place_id FROM tours WHERE place_id = 4').get()).toEqual({ place_id: 4 });
|
|
expect(assignments[1]).toMatchObject({ place: { id: 3 }, tour_place_id: null, tour_route_geometry: null });
|
|
expect(assignments.slice(2).map(a => ({
|
|
place_id: a.place.id,
|
|
tour_place_id: a.tour_place_id,
|
|
tour_route_geometry: a.tour_route_geometry,
|
|
}))).toEqual([
|
|
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
|
{ place_id: 4, tour_place_id: 4, tour_route_geometry: tourGeometry },
|
|
]);
|
|
});
|
|
|
|
it('201 create day (real insert, auto day_number), 404 trip when not accessible', async () => {
|
|
const ok = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ date: '2026-07-01' });
|
|
expect(ok.status).toBe(201);
|
|
expect(ok.body.day).toMatchObject({ trip_id: 5, day_number: 2, date: '2026-07-01', assignments: [] });
|
|
const row = db.prepare('SELECT * FROM days WHERE id = ?').get(ok.body.day.id);
|
|
expect(row).toMatchObject({ trip_id: 5, day_number: 2, date: '2026-07-01' });
|
|
db.prepare('DELETE FROM days WHERE id = ?').run(ok.body.day.id);
|
|
const miss = await request(server).get('/api/trips/77/days').set('Cookie', sessionCookie(1));
|
|
expect(miss.status).toBe(404);
|
|
expect(miss.body).toEqual({ error: 'Trip not found' });
|
|
});
|
|
|
|
it('201 create dated day extends the trip, and the days without a date move back', async () => {
|
|
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (10, 1, ?, ?, ?)').run('Grow', '2026-09-01', '2026-09-02');
|
|
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (10, 1, ?)').run('2026-09-01');
|
|
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (10, 2, ?)').run('2026-09-02');
|
|
const spare = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (10, 3)').run().lastInsertRowid);
|
|
const res = await request(server).post('/api/trips/10/days').set('Cookie', sessionCookie(1)).send({ dated: true });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.day).toMatchObject({ trip_id: 10, day_number: 3, date: '2026-09-03', assignments: [], notes_items: [] });
|
|
expect(res.body.trip).toMatchObject({ id: 10, end_date: '2026-09-03', day_count: 4, is_owner: 1 });
|
|
expect(db.prepare('SELECT end_date FROM trips WHERE id = 10').get()).toEqual({ end_date: '2026-09-03' });
|
|
expect(db.prepare('SELECT day_number, date FROM days WHERE id = ?').get(spare)).toEqual({ day_number: 4, date: null });
|
|
});
|
|
|
|
it('400 dated with position, 400 dated on a trip without dates', async () => {
|
|
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (11, 1, ?, ?, ?)').run('Mixed', '2026-09-01', '2026-09-01');
|
|
db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (11, 1, ?)').run('2026-09-01');
|
|
const mixed = await request(server).post('/api/trips/11/days').set('Cookie', sessionCookie(1)).send({ dated: true, position: 1 });
|
|
expect(mixed.status).toBe(400);
|
|
expect(db.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = 11').get()).toEqual({ n: 1 });
|
|
const undated = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ dated: true });
|
|
expect(undated.status).toBe(400);
|
|
expect(undated.body).toEqual({ error: 'This trip has no dates. Add a day without a date instead.' });
|
|
expect(db.prepare('SELECT COUNT(*) AS n FROM days WHERE trip_id = 5').get()).toEqual({ n: 1 });
|
|
});
|
|
|
|
it('200 update day notes/title, 404 Day not found, 403 without permission', async () => {
|
|
const res = await request(server).put('/api/trips/5/days/3').set('Cookie', sessionCookie(1))
|
|
.send({ notes: 'Walking day', title: 'Arrival' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.day).toMatchObject({ id: 3, notes: 'Walking day', title: 'Arrival', assignments: [] });
|
|
// The client updates title and notes in separate requests — an omitted
|
|
// field must survive (post-port defect fix: the legacy update always wrote
|
|
// both columns, so a title-only PUT wiped the notes).
|
|
const titleOnly = await request(server).put('/api/trips/5/days/3').set('Cookie', sessionCookie(1))
|
|
.send({ title: 'Renamed' });
|
|
expect(titleOnly.status).toBe(200);
|
|
expect(titleOnly.body.day).toMatchObject({ id: 3, notes: 'Walking day', title: 'Renamed' });
|
|
const notesOnly = await request(server).put('/api/trips/5/days/3').set('Cookie', sessionCookie(1))
|
|
.send({ notes: 'Museum day' });
|
|
expect(notesOnly.status).toBe(200);
|
|
expect(notesOnly.body.day).toMatchObject({ id: 3, notes: 'Museum day', title: 'Renamed' });
|
|
const miss = await request(server).put('/api/trips/5/days/99').set('Cookie', sessionCookie(1)).send({ notes: 'x' });
|
|
expect(miss.status).toBe(404);
|
|
expect(miss.body).toEqual({ error: 'Day not found' });
|
|
checkPermission.mockReturnValue(false);
|
|
const forbidden = await request(server).put('/api/trips/5/days/3').set('Cookie', sessionCookie(1)).send({ notes: 'x' });
|
|
expect(forbidden.status).toBe(403);
|
|
expect(forbidden.body).toEqual({ error: 'No permission' });
|
|
});
|
|
|
|
it('200 transport setter changes only default_transport_mode', async () => {
|
|
db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = 3').run('Keep', 'Kept');
|
|
const res = await request(server).put('/api/trips/5/days/3/transport').set('Cookie', sessionCookie(1))
|
|
.send({ transport_mode: 'walk' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.day).toMatchObject({ id: 3, default_transport_mode: 'walk', notes: 'Keep', title: 'Kept' });
|
|
});
|
|
|
|
it('200 reorder permutes day_number, 400 on a non-permutation', async () => {
|
|
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (6, 1, ?)').run('Reorder');
|
|
const a = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (6, 1, ?)').run('2026-03-01').lastInsertRowid);
|
|
const b = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (6, 2, ?)').run('2026-03-02').lastInsertRowid);
|
|
const ok = await request(server).put('/api/trips/6/days/reorder').set('Cookie', sessionCookie(1))
|
|
.send({ orderedIds: [b, a] });
|
|
expect(ok.status).toBe(200);
|
|
expect(ok.body).toEqual({ success: true });
|
|
const after = db.prepare('SELECT id, date FROM days WHERE trip_id = 6 ORDER BY day_number').all() as { id: number; date: string }[];
|
|
// Dates stay pinned to slots; the rows swapped positions.
|
|
expect(after.map(d => d.id)).toEqual([b, a]);
|
|
expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02']);
|
|
const bad = await request(server).put('/api/trips/6/days/reorder').set('Cookie', sessionCookie(1))
|
|
.send({ orderedIds: [b] });
|
|
expect(bad.status).toBe(400);
|
|
expect(bad.body).toEqual({ error: 'orderedIds must be a permutation of the trip day ids.' });
|
|
});
|
|
|
|
it('200 delete day removes the row, closes the gap and answers with the trip', async () => {
|
|
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (7, 1, ?)').run('Delete');
|
|
const id = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 1)').run().lastInsertRowid);
|
|
const kept = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (7, 2)').run().lastInsertRowid);
|
|
const res = await request(server).delete(`/api/trips/7/days/${id}`).set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toMatchObject({ success: true, trip: { id: 7, day_count: 1, is_owner: 1 } });
|
|
expect(db.prepare('SELECT * FROM days WHERE id = ?').get(id)).toBeUndefined();
|
|
expect(db.prepare('SELECT day_number FROM days WHERE id = ?').get(kept)).toEqual({ day_number: 1 });
|
|
});
|
|
|
|
it('200 delete a dated day with no spare day ends the trip a day earlier', async () => {
|
|
db.prepare('INSERT INTO trips (id, user_id, title, start_date, end_date) VALUES (8, 1, ?, ?, ?)').run('Shrink', '2026-09-01', '2026-09-02');
|
|
const first = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 1, ?)').run('2026-09-01').lastInsertRowid);
|
|
const second = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (8, 2, ?)').run('2026-09-02').lastInsertRowid);
|
|
const res = await request(server).delete(`/api/trips/8/days/${first}`).set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.trip).toMatchObject({ id: 8, end_date: '2026-09-01', day_count: 1 });
|
|
expect(db.prepare('SELECT day_number, date FROM days WHERE id = ?').get(second)).toEqual({ day_number: 1, date: '2026-09-01' });
|
|
});
|
|
|
|
it('400 delete the last day of a trip, 403 without day_edit', async () => {
|
|
db.prepare('INSERT INTO trips (id, user_id, title) VALUES (9, 1, ?)').run('Last');
|
|
const only = Number(db.prepare('INSERT INTO days (trip_id, day_number) VALUES (9, 1)').run().lastInsertRowid);
|
|
const last = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1));
|
|
expect(last.status).toBe(400);
|
|
expect(last.body).toEqual({ error: 'A trip needs at least one day.' });
|
|
expect(db.prepare('SELECT id FROM days WHERE id = ?').get(only)).toEqual({ id: only });
|
|
checkPermission.mockReturnValue(false);
|
|
const forbidden = await request(server).delete(`/api/trips/9/days/${only}`).set('Cookie', sessionCookie(1));
|
|
expect(forbidden.status).toBe(403);
|
|
expect(forbidden.body).toEqual({ error: 'No permission' });
|
|
});
|
|
|
|
it('201 create note (real insert: trim, empty-string coercions), 400 on over-long text (before access)', async () => {
|
|
const ok = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1))
|
|
.send({ text: ' Lunch ', time: '', icon: '', sort_order: 0 });
|
|
expect(ok.status).toBe(201);
|
|
expect(ok.body.note).toMatchObject({ day_id: 3, trip_id: 5, text: 'Lunch', time: null, icon: '📝', sort_order: 0 });
|
|
const row = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(ok.body.note.id);
|
|
expect(row).toMatchObject({ text: 'Lunch', time: null, icon: '📝', sort_order: 0 });
|
|
const long = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: 'x'.repeat(501) });
|
|
expect(long.status).toBe(400);
|
|
expect(long.body.error).toContain('text');
|
|
});
|
|
|
|
it('201 create accepts null time/icon (moveDayNote re-sends the nullable entity fields)', async () => {
|
|
const res = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1))
|
|
.send({ text: 'Moved', time: null, icon: null, sort_order: 3 });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.note).toMatchObject({ text: 'Moved', time: null, icon: '📝', sort_order: 3 });
|
|
});
|
|
|
|
it('404 Day not found when the day is not on the trip', async () => {
|
|
const res = await request(server).post('/api/trips/5/days/99/notes').set('Cookie', sessionCookie(1)).send({ text: 'Lunch' });
|
|
expect(res.status).toBe(404);
|
|
expect(res.body).toEqual({ error: 'Day not found' });
|
|
});
|
|
|
|
it('400 note without text', async () => {
|
|
const res = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: ' ' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body).toEqual({ error: 'Text required' });
|
|
});
|
|
|
|
it('200 update note merges omitted fields from the current row', async () => {
|
|
const created = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1))
|
|
.send({ text: 'Lunch', time: '12:00' });
|
|
const id = created.body.note.id;
|
|
const res = await request(server).put(`/api/trips/5/days/3/notes/${id}`).set('Cookie', sessionCookie(1))
|
|
.send({ icon: '🍜' });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.note).toMatchObject({ id, text: 'Lunch', time: '12:00', icon: '🍜' });
|
|
const miss = await request(server).put('/api/trips/5/days/3/notes/9999').set('Cookie', sessionCookie(1)).send({ text: 'x' });
|
|
expect(miss.status).toBe(404);
|
|
expect(miss.body).toEqual({ error: 'Note not found' });
|
|
});
|
|
|
|
it('200 delete note removes the row', async () => {
|
|
const created = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: 'Lunch' });
|
|
const id = created.body.note.id;
|
|
const res = await request(server).delete(`/api/trips/5/days/3/notes/${id}`).set('Cookie', sessionCookie(1));
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ success: true });
|
|
expect(db.prepare('SELECT * FROM day_notes WHERE id = ?').get(id)).toBeUndefined();
|
|
});
|
|
});
|