/** * Assignments module e2e — exercises both migrated controllers through the real * JwtAuthGuard against a temp SQLite db. AssignmentsService runs its real SQL * (DI-injected, no service mock); journeyService, the permission check, * canAccessTrip 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 { DatabaseModule } from '../../src/nest/database/database.module'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { Test } from '@nestjs/testing'; 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'); // Only what the un-mocked DI service actually touches: the auth guard reads // users; AssignmentsService's real SQL reads/writes the itinerary tables // (display_name/avatar feed the participants COALESCE projection). 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, display_name TEXT, avatar TEXT);`); tmp.exec(`CREATE TABLE days (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL);`); tmp.exec(`CREATE TABLE categories (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, icon TEXT);`); tmp.exec(`CREATE TABLE places (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER NOT NULL, name TEXT, description TEXT, lat REAL, lng REAL, address TEXT, category_id INTEGER, price REAL, currency TEXT, place_time TEXT, end_time TEXT, duration_minutes INTEGER DEFAULT 60, notes TEXT, image_url TEXT, transport_mode TEXT DEFAULT 'walking', google_place_id TEXT, google_ftid TEXT, osm_id TEXT, amap_poi_id TEXT, website TEXT, phone TEXT, stop_type TEXT, fill_percent INTEGER);`); tmp.exec(`CREATE TABLE day_assignments (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, place_id INTEGER NOT NULL, order_index INTEGER NOT NULL DEFAULT 0, notes TEXT, assignment_time TEXT, assignment_end_time TEXT, leg_transport_mode TEXT, accommodation_id INTEGER, created_at TEXT DEFAULT (datetime('now')));`); // The auto-sort reads a booked night's hour off its booking, so the table has to be // here even though nothing in this file books one. tmp.exec(`CREATE TABLE day_accommodations (id INTEGER PRIMARY KEY AUTOINCREMENT, trip_id INTEGER, place_id INTEGER, start_day_id INTEGER, end_day_id INTEGER, check_in TEXT, check_in_end TEXT, check_out TEXT, confirmation TEXT, notes TEXT, created_at TEXT DEFAULT (datetime('now')));`); tmp.exec(`CREATE TABLE assignment_participants (assignment_id INTEGER NOT NULL, user_id INTEGER NOT NULL, UNIQUE(assignment_id, user_id));`); // A start that reorders a day re-pins that day's vias in the same transaction, so // the sort reads this table whenever it moves a stop. tmp.exec(`CREATE TABLE roadtrip_vias (id INTEGER PRIMARY KEY AUTOINCREMENT, day_id INTEGER NOT NULL, 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 tags (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, color TEXT, created_at TEXT);`); tmp.exec(`CREATE TABLE place_tags (place_id INTEGER NOT NULL, tag_id INTEGER NOT NULL);`); // StorageRegistryService (behind StorageModule, now in this module chain) reads // this at onModuleInit. tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); return { db: tmp }; }); const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); vi.mock('../../src/db/database', () => ({ db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, })); const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() })); vi.mock('../../src/websocket', () => ({ broadcast })); const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn() })); import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; 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 { AssignmentsModule } from '../../src/nest/assignments/assignments.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; describe('Assignments e2e (real auth guard + temp SQLite)', () => { let server: Server; let app: Awaited>; async function build() { const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, AssignmentsModule] }) .overrideProvider(JourneyDomainService) .useValue({ reconcileTripSkeletons }) .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, username: 'peer', email: 'peer@example.test' }); db.prepare('INSERT INTO days (id, trip_id) VALUES (3, 5), (4, 5)').run(); db.prepare('INSERT INTO places (id, trip_id, name) VALUES (2, 5, ?)').run('Louvre'); app = await build(); checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission'); server = app.getHttpServer(); }); beforeEach(() => { canAccessTrip.mockReturnValue({ id: 5, user_id: 1 }); checkPermission.mockReturnValue(true); db.prepare('DELETE FROM day_assignments').run(); db.prepare('DELETE FROM assignment_participants').run(); }); afterAll(async () => { await app.close(); }); const seedAssignment = (dayId = 3, placeId = 2, orderIndex = 0) => Number(db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)').run(dayId, placeId, orderIndex).lastInsertRowid); it('401 without a cookie', async () => { expect((await request(server).get('/api/trips/5/days/3/assignments')).status).toBe(401); }); it('200 list day-assignments', async () => { const id = seedAssignment(); const res = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body.assignments).toHaveLength(1); expect(res.body.assignments[0]).toMatchObject({ id, day_id: 3, place_id: 2, order_index: 0, participants: [], place: { id: 2, name: 'Louvre', tags: [] }, }); }); it('201 create, 404 place', async () => { reconcileTripSkeletons.mockClear(); const ok = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 2 }); expect(ok.status).toBe(201); expect(ok.body.assignment).toMatchObject({ day_id: 3, place_id: 2, order_index: 0, notes: null, place: { id: 2, name: 'Louvre' } }); const row = db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(ok.body.assignment.id); expect(row).toMatchObject({ day_id: 3, place_id: 2, order_index: 0 }); expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); const miss = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 99 }); expect(miss.status).toBe(404); expect(miss.body).toEqual({ error: 'Place not found' }); }); it('200 delete assignment reconciles journey skeletons', async () => { reconcileTripSkeletons.mockClear(); const id = seedAssignment(); const res = await request(server).delete(`/api/trips/5/days/3/assignments/${id}`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); expect(db.prepare('SELECT id FROM day_assignments WHERE id = ?').get(id)).toBeUndefined(); expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); }); it('200 move assignment reconciles journey skeletons', async () => { reconcileTripSkeletons.mockClear(); const id = seedAssignment(); const res = await request(server) .put(`/api/trips/5/assignments/${id}/move`) .set('Cookie', sessionCookie(1)) .send({ new_day_id: 4, order_index: 0 }); expect(res.status).toBe(200); expect(res.body.assignment).toMatchObject({ id, day_id: 4, order_index: 0 }); expect(db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id)).toEqual({ day_id: 4 }); expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); }); it('200 notes roundtrip: create with note, PUT edits it, GET list shows the new value (#2163)', async () => { const create = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)) .send({ place_id: 2, notes: 'Book the 10:00 timed entry' }); expect(create.status).toBe(201); expect(create.body.assignment.notes).toBe('Book the 10:00 timed entry'); const id = create.body.assignment.id; const put = await request(server) .put(`/api/trips/5/assignments/${id}/notes`) .set('Cookie', sessionCookie(1)) .send({ notes: 'Arrive 15 minutes early' }); expect(put.status).toBe(200); expect(put.body.assignment).toMatchObject({ id, notes: 'Arrive 15 minutes early' }); expect(db.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(id)).toEqual({ notes: 'Arrive 15 minutes early' }); const list = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)); expect(list.status).toBe(200); expect(list.body.assignments.find((a: { id: number }) => a.id === id).notes).toBe('Arrive 15 minutes early'); }); it('200 notes clear: null and empty string both null the column (#2163)', async () => { const id = seedAssignment(); db.prepare('UPDATE day_assignments SET notes = ? WHERE id = ?').run('old note', id); const cleared = await request(server).put(`/api/trips/5/assignments/${id}/notes`).set('Cookie', sessionCookie(1)).send({ notes: null }); expect(cleared.status).toBe(200); expect(cleared.body.assignment.notes).toBeNull(); db.prepare('UPDATE day_assignments SET notes = ? WHERE id = ?').run('old note', id); const emptied = await request(server).put(`/api/trips/5/assignments/${id}/notes`).set('Cookie', sessionCookie(1)).send({ notes: '' }); expect(emptied.status).toBe(200); expect(db.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(id)).toEqual({ notes: null }); }); it('400 notes body without the notes key is rejected by the Zod pipe (#2163)', async () => { const id = seedAssignment(); const res = await request(server).put(`/api/trips/5/assignments/${id}/notes`).set('Cookie', sessionCookie(1)).send({}); expect(res.status).toBe(400); }); it('200 update time reconciles journey skeletons', async () => { reconcileTripSkeletons.mockClear(); const id = seedAssignment(); const res = await request(server) .put(`/api/trips/5/assignments/${id}/time`) .set('Cookie', sessionCookie(1)) .send({ place_time: '09:00', end_time: null }); expect(res.status).toBe(200); expect(res.body.assignment).toMatchObject({ id, assignment_time: '09:00', assignment_end_time: null }); expect(db.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(id)).toEqual({ assignment_time: '09:00' }); expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined); }); describe('PUT /:id/time and the order of the day', () => { const seedDay = (times: (string | null)[]) => times.map((time, i) => { const id = seedAssignment(3, 2, i); if (time) db.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run(time, id); return id; }); const dayOrder = () => (db.prepare('SELECT id FROM day_assignments WHERE day_id = 3 ORDER BY order_index, id').all() as { id: number }[]).map(r => r.id); const eventsSent = () => broadcast.mock.calls.map(call => call[1]); beforeEach(() => broadcast.mockClear()); it('keeps the untimed stops in front of the stop that gets a start', async () => { const [a, b, c] = seedDay([null, null, null]); const res = await request(server) .put(`/api/trips/5/assignments/${c}/time`) .set('Cookie', sessionCookie(1)) .set('X-Socket-Id', 'sock-1') .send({ place_time: '14:00', end_time: null }); expect(res.status).toBe(200); expect(res.body.assignment).toMatchObject({ id: c, assignment_time: '14:00', order_index: 2 }); expect(dayOrder()).toEqual([a, b, c]); expect(eventsSent()).toEqual(['assignment:updated']); }); it('sorts the timed stops, keeps the untimed head first and sends the whole day', async () => { const [a, b, c] = seedDay([null, '15:00', null]); const res = await request(server) .put(`/api/trips/5/assignments/${c}/time`) .set('Cookie', sessionCookie(1)) .set('X-Socket-Id', 'sock-1') .send({ place_time: '10:00', end_time: null }); expect(res.status).toBe(200); expect(dayOrder()).toEqual([a, c, b]); // No socket left out, so the writer gets the order too. expect(broadcast).toHaveBeenCalledWith('5', 'assignment:reordered', { dayId: 3, orderedIds: [a, c, b] }, undefined); // No located stops and no vias on this day, so there is nothing to re-pin. expect(eventsSent()).not.toContain('roadtripVia:changed'); }); it('stores the order it sends: a day with a gap in its keys is numbered from 0', async () => { // The gap a deleted stop leaves. Clients number the ids they are sent by position. const [a, b, c] = [0, 4, 7].map(key => seedAssignment(3, 2, key)); db.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('15:00', b); const res = await request(server) .put(`/api/trips/5/assignments/${c}/time`) .set('Cookie', sessionCookie(1)) .send({ place_time: '10:00', end_time: null }); expect(res.status).toBe(200); const sent = broadcast.mock.calls.find(call => call[1] === 'assignment:reordered')?.[2] as { orderedIds: number[] }; expect(sent.orderedIds).toEqual([a, c, b]); const keyOf = (id: number) => (db.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(id) as { order_index: number }).order_index; expect(sent.orderedIds.map(keyOf)).toEqual([0, 1, 2]); expect(res.body.assignment).toMatchObject({ id: c, order_index: 1 }); }); it('leaves a day dragged out of time order alone when only the End changes', async () => { const [a, b] = seedDay(['14:00', '10:00']); db.prepare("UPDATE day_assignments SET assignment_end_time = '11:00' WHERE id = ?").run(b); const res = await request(server) .put(`/api/trips/5/assignments/${b}/time`) .set('Cookie', sessionCookie(1)) .set('X-Socket-Id', 'sock-1') .send({ place_time: '10:00', end_time: null }); expect(res.status).toBe(200); expect(res.body.assignment).toMatchObject({ id: b, assignment_time: '10:00', assignment_end_time: null }); expect(dayOrder()).toEqual([a, b]); expect(eventsSent()).toEqual(['assignment:updated']); }); }); it('200 participants (access-only)', async () => { const id = seedAssignment(); db.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, 2)').run(id); const res = await request(server).get(`/api/trips/5/assignments/${id}/participants`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ participants: [{ user_id: 2, username: 'peer', avatar: null }] }); }); it('400 from the Zod pipe on set participants with non-array', async () => { const res = await request(server).put('/api/trips/5/assignments/9/participants').set('Cookie', sessionCookie(1)).send({ user_ids: 'no' }); expect(res.status).toBe(400); expect(res.body.error).toContain('user_ids'); }); it('400 from the Zod pipe on create without a place_id', async () => { const res = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({}); expect(res.status).toBe(400); expect(res.body.error).toContain('place_id'); }); it('move accepts the client api form order_index: null (coerces to 0)', async () => { const id = seedAssignment(); const res = await request(server) .put(`/api/trips/5/assignments/${id}/move`) .set('Cookie', sessionCookie(1)) .send({ new_day_id: 4, order_index: null }); expect(res.status).toBe(200); expect(res.body.assignment).toMatchObject({ id, day_id: 4, order_index: 0 }); }); // The per-assignment controller declared @RequirePermission but not the guard // that reads it, so the decorators were inert metadata and :tripId was never // checked against the caller at all. The handlers that ask // getAssignmentForTrip(id, tripId) were happy as long as the assignment sat on // the trip in the URL — which is true for the owner's trip too. describe('a trip the caller cannot see', () => { const FOREIGN_TRIP = 9; beforeEach(() => { canAccessTrip.mockImplementation((tripId: unknown) => (Number(tripId) === 5 ? { id: 5, user_id: 1 } : undefined)); db.prepare('INSERT OR IGNORE INTO days (id, trip_id) VALUES (30, ?), (31, ?)').run(FOREIGN_TRIP, FOREIGN_TRIP); db.prepare('INSERT OR IGNORE INTO places (id, trip_id, name) VALUES (20, ?, ?)').run(FOREIGN_TRIP, 'Their hotel'); }); const seedForeignAssignment = () => Number(db.prepare('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (30, 20, 0)').run().lastInsertRowid); it('404s a move instead of reordering their itinerary', async () => { const id = seedForeignAssignment(); const res = await request(server) .put(`/api/trips/${FOREIGN_TRIP}/assignments/${id}/move`) .set('Cookie', sessionCookie(1)) .send({ new_day_id: 31, order_index: 0 }); expect(res.status).toBe(404); expect(db.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(id)).toEqual({ day_id: 30 }); }); it('404s a time change instead of rewriting their schedule', async () => { const id = seedForeignAssignment(); const res = await request(server) .put(`/api/trips/${FOREIGN_TRIP}/assignments/${id}/time`) .set('Cookie', sessionCookie(1)) .send({ place_time: '23:00', end_time: null }); expect(res.status).toBe(404); expect(db.prepare('SELECT assignment_time FROM day_assignments WHERE id = ?').get(id)).toEqual({ assignment_time: null }); }); it('404s a transport change', async () => { const id = seedForeignAssignment(); const res = await request(server) .put(`/api/trips/${FOREIGN_TRIP}/assignments/${id}/transport`) .set('Cookie', sessionCookie(1)) .send({ transport_mode: 'driving' }); expect(res.status).toBe(404); }); it('404s setting participants instead of writing to their assignment', async () => { const id = seedForeignAssignment(); const res = await request(server) .put(`/api/trips/${FOREIGN_TRIP}/assignments/${id}/participants`) .set('Cookie', sessionCookie(1)) .send({ user_ids: [1] }); expect(res.status).toBe(404); expect(db.prepare('SELECT COUNT(*) AS n FROM assignment_participants WHERE assignment_id = ?').get(id)).toEqual({ n: 0 }); }); it('404s reading participants instead of disclosing who is on it', async () => { const id = seedForeignAssignment(); db.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, 2)').run(id); const res = await request(server) .get(`/api/trips/${FOREIGN_TRIP}/assignments/${id}/participants`) .set('Cookie', sessionCookie(1)); expect(res.status).toBe(404); }); // The guard has to reject an assignment borrowed from elsewhere even when the // caller is legitimately on the trip named in the URL. it('404s an assignment that belongs to another trip than the URL says', async () => { const id = seedForeignAssignment(); const res = await request(server) .put(`/api/trips/5/assignments/${id}/participants`) .set('Cookie', sessionCookie(1)) .send({ user_ids: [1] }); expect(res.status).toBe(404); expect(db.prepare('SELECT COUNT(*) AS n FROM assignment_participants WHERE assignment_id = ?').get(id)).toEqual({ n: 0 }); }); it('404s a notes change on a foreign assignment instead of editing it (#2163)', async () => { const id = seedForeignAssignment(); const res = await request(server) .put(`/api/trips/${FOREIGN_TRIP}/assignments/${id}/notes`) .set('Cookie', sessionCookie(1)) .send({ notes: 'hijacked' }); expect(res.status).toBe(404); expect(db.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(id)).toEqual({ notes: null }); }); it('403s when the caller is on the trip but lacks day_edit', async () => { const id = seedAssignment(); checkPermission.mockReturnValue(false); const res = await request(server) .put(`/api/trips/5/assignments/${id}/time`) .set('Cookie', sessionCookie(1)) .send({ place_time: '09:00', end_time: null }); expect(res.status).toBe(403); }); it('403s a notes change without day_edit (#2163)', async () => { const id = seedAssignment(); checkPermission.mockReturnValue(false); const res = await request(server) .put(`/api/trips/5/assignments/${id}/notes`) .set('Cookie', sessionCookie(1)) .send({ notes: 'nope' }); expect(res.status).toBe(403); expect(db.prepare('SELECT notes FROM day_assignments WHERE id = ?').get(id)).toEqual({ notes: null }); }); }); });