mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
190 lines
9.5 KiB
TypeScript
190 lines
9.5 KiB
TypeScript
/**
|
|
* 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 } 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 { 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, website TEXT, phone TEXT);`);
|
|
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,
|
|
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));`);
|
|
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);`);
|
|
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: () => {},
|
|
}));
|
|
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
|
|
|
const { reconcileTripSkeletons } = vi.hoisted(() => ({ reconcileTripSkeletons: vi.fn() }));
|
|
vi.mock('../../src/services/journeyService', () => ({ reconcileTripSkeletons }));
|
|
|
|
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
|
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
|
|
|
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<ReturnType<typeof build>>;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AssignmentsModule] }).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();
|
|
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 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);
|
|
});
|
|
|
|
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 });
|
|
});
|
|
});
|