Files
TREK/server/tests/e2e/assignments.e2e.test.ts
T
2026-10-04 12:59:55 +02:00

525 lines
27 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, 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';
vi.mock('../../src/config', async () => {
const { readEnv } = await import('../../src/app-config');
const env = readEnv();
return {
ENCRYPTION_KEY: 'assignments-e2e-inert-encryption-key',
JWT_SECRET: 'assignments-e2e-inert-jwt-secret',
updateJwtSecret: vi.fn(),
DEFAULT_LANGUAGE: env.app.defaultLanguage,
SESSION_DURATION: env.session.duration,
SESSION_DURATION_MS: env.session.durationMs,
SESSION_DURATION_SECONDS: env.session.durationSeconds,
SESSION_DURATION_REMEMBER: env.session.durationRemember,
SESSION_DURATION_REMEMBER_MS: env.session.durationRememberMs,
SESSION_DURATION_REMEMBER_SECONDS: env.session.durationRememberSeconds,
};
});
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, route_geometry TEXT);`);
tmp.exec('CREATE TABLE tours (place_id INTEGER PRIMARY KEY);');
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, route_excluded INTEGER NOT NULL DEFAULT 0,
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<ReturnType<typeof build>>;
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('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 (3, 5, ?, ?), (4, 5, ?, ?)')
.run('Legacy track', legacyGeometry, 'Tour', tourGeometry);
db.prepare('INSERT INTO tours (place_id) VALUES (4)').run();
const assignmentIds = [
seedAssignment(3, 2, 0),
seedAssignment(3, 3, 1),
seedAssignment(3, 4, 2),
seedAssignment(3, 4, 3),
];
const res = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
const assignments = res.body.assignments as Array<{
id: number;
place_id: number;
order_index: number;
tour_place_id: number | null;
tour_route_geometry: string | null;
}>;
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, 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 clear day removes every place of the day and keeps the day (#2470)', async () => {
reconcileTripSkeletons.mockClear();
const first = seedAssignment(3, 2, 0);
const second = seedAssignment(3, 2, 1);
const elsewhere = seedAssignment(4, 2, 0);
const res = await request(server).delete('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect([...res.body.removedIds].sort()).toEqual([first, second].sort());
expect(db.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = 3').get()).toEqual({ n: 0 });
expect(db.prepare('SELECT id FROM day_assignments WHERE id = ?').get(elsewhere)).toEqual({ id: elsewhere });
expect(reconcileTripSkeletons).toHaveBeenCalledWith(5, undefined);
});
it('403 clear day without day_edit, 404 for a foreign day', async () => {
seedAssignment();
checkPermission.mockReturnValue(false);
expect((await request(server).delete('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1))).status).toBe(403);
checkPermission.mockReturnValue(true);
const miss = await request(server).delete('/api/trips/5/days/999/assignments').set('Cookie', sessionCookie(1));
expect(miss.status).toBe(404);
expect(miss.body).toEqual({ error: 'Day not found' });
});
it('200 route exclude roundtrip, 400 without a boolean (#2532)', async () => {
const id = seedAssignment();
const res = await request(server).put(`/api/trips/5/assignments/${id}/route`).set('Cookie', sessionCookie(1)).send({ excluded: true });
expect(res.status).toBe(200);
expect(res.body.assignment).toMatchObject({ id, route_excluded: true });
const list = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1));
expect(list.body.assignments[0].route_excluded).toBe(true);
expect((await request(server).put(`/api/trips/5/assignments/${id}/route`).set('Cookie', sessionCookie(1)).send({ excluded: 'yes' })).status).toBe(400);
});
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 });
});
});
});