mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
cc1c16ec34
Saving a start on a stop rewrote the whole day as the timed stops in time order followed by every untimed stop. Its own comment and the commit that added it promised that untimed stops keep their place, but a day of two untimed stops and a third one given 14:00 came back with the third one first. On a road trip the stop order is the drive, so the day was re-routed, its vias landed on other legs, and collaborators, who only got the one edited row, saw a third order until they reloaded. The intended rule, an untimed stop inheriting the time of the timed stop before it, now lives once as chronoOrder in @trek/shared, used by both the day plan (dayMerge) and AssignmentsService.updateTime. The server still reads a booked night's hour from its check-in, and a value without a colon still sorts after every clock time. The day is sorted only when the stop's start actually changed, so taking an End off or saving an unchanged start no longer snaps back a day somebody dragged out of time order. A sorted day is numbered from 0 like a drag. In the same transaction the day's road trip vias follow the stop they were drawn behind, the planner's own drag rule, and a via behind a last stop that stays last is kept, so the drive into the next day keeps its road. PUT /assignments/:id/time and the MCP update_assignment_time tool both broadcast assignment:reordered with the whole day, to the writer as well, and roadtripVia:changed when vias moved, so every client ends up with the same order. ASG-SVC-022, 023 and 027 pinned the old behaviour and now pin the intended one.
432 lines
22 KiB
TypeScript
432 lines
22 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';
|
|
|
|
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<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('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 });
|
|
});
|
|
});
|
|
});
|