Files
TREK/server/tests/e2e/reservations.e2e.test.ts
T
Maurice f0e1da2d81 feat(accommodations): put a booked night on its check-in day
A road-trip stop is a day assignment: the rail, the server-side plan and the
map all build their stops from day_assignments and only look the stay up
afterwards, to hang check-in and check-out on one. A stay written without an
assignment was therefore invisible to every routing surface, so a hotel entered
in the day planner never reached the road-trip view and had to be entered a
second time as an ordinary place. The road-trip side has always written both
halves in one go; this is the day planner catching up, in the domain that
writes the stay.

Booking a night now also assigns its place to the check-in day, inside the
transaction that already writes the stay and its partner hotel reservation, and
types the place as a hotel stop when it carries no type of its own, so it reads
exactly like a night booked in the road trip. Only the check-in day gets a
stop; the later nights ride on the stay row. A place already planned for that
day gets no second stop and the booking claims none, so cancelling it cannot
delete a stop somebody placed by hand. Moving the booking moves its own stop,
deleting it takes its own stop back, and a trip re-date carries it along with
the stay. All four write surfaces (REST, plugin RPC and both MCP tools) run the
same write and announce the same events; the answers carry the stop too,
because the broadcast skips the socket that sent the request.

New nullable day_assignments.accommodation_id marks a stop as a booking's own.
It stays NULL for every existing row on purpose: everything planned before this
was planned by hand, and no backfill rewrites a finished trip. A stay booked
earlier picks up its stop the next time somebody saves it.

The booking form keeps writing stays on its own: that surface is gated on
reservation_edit and the day stop is day_edit work.
2026-09-13 01:13:25 +02:00

301 lines
16 KiB
TypeScript

/**
* Reservations + accommodations module e2e — exercises both migrated mounts
* through the real JwtAuthGuard against a temp SQLite db. Reservation SQL runs
* for real (ReservationsService is DI-native, no mock — the temp db carries the
* full schema via createTables + runMigrations), and so does the accommodation
* SQL (the injected DaysService is DI-native too); the budget service, the
* permission check and the WebSocket broadcast stay mocked.
*/
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
// Accommodations left reservations/ for a domain of their own; this container has
// to assemble both or the /accommodations cases below 404 while production serves them.
import { AccommodationsModule } from '../../src/nest/accommodations/accommodations.module';
import { sessionCookie } from './harness';
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { Test } from '@nestjs/testing';
import cookieParser from 'cookie-parser';
import type { Server } from 'http';
import request from 'supertest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type MockInstance } from 'vitest';
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');
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 { notificationSend } = vi.hoisted(() => ({ notificationSend: vi.fn().mockResolvedValue(undefined) }));
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;
// The budget-sync seam runs the real injected BudgetService (BudgetModule is
// imported by ReservationsModule since the budget fold) over the same temp db.
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { NotificationsService } from '../../src/nest/notifications/notifications.service';
describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real reservation SQL)', () => {
let server: Server;
let app: Awaited<ReturnType<typeof build>>;
let tripId: number;
async function build() {
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RealtimeModule, ReservationsModule, AccommodationsModule] })
.overrideProvider(NotificationsService)
.useValue({ send: notificationSend })
.compile();
const nest = moduleRef.createNestApplication();
nest.use(cookieParser());
nest.useGlobalFilters(new TrekExceptionFilter());
nest.useGlobalPipes(new ZodValidationPipe());
await nest.init();
return nest;
}
beforeAll(async () => {
createTables(db);
runMigrations(db);
// The temp db carries the real schema (password_hash NOT NULL), so seed the
// auth user directly instead of via the trimmed-DDL seedUser helper.
db.prepare(
"INSERT INTO users (id, username, email, password_hash, role, password_version) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user', 0)",
).run();
tripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (1, 'E2E Trip')").run().lastInsertRowid);
app = await build();
checkPermission = vi.spyOn(app.get(PermissionsService), 'checkPermission');
server = app.getHttpServer();
});
beforeEach(() => {
canAccessTrip.mockImplementation((id: unknown) => db.prepare('SELECT * FROM trips WHERE id = ?').get(id));
checkPermission.mockReturnValue(true);
notificationSend.mockClear();
});
afterAll(async () => {
await app.close();
});
it('401 without a cookie (reservations)', async () => {
expect((await request(server).get(`/api/trips/${tripId}/reservations`)).status).toBe(401);
});
it('200 list reservations (real SQL, joins attached)', async () => {
const rid = db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Hotel', 'hotel')").run(tripId).lastInsertRowid;
const res = await request(server).get(`/api/trips/${tripId}/reservations`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
const row = res.body.reservations.find((r: { id: number }) => r.id === Number(rid));
expect(row).toMatchObject({ title: 'Hotel', type: 'hotel', endpoints: [], travelers: [] });
});
it('401 without a cookie (upcoming feed)', async () => {
expect((await request(server).get('/api/reservations/upcoming')).status).toBe(401);
});
it('200 cross-trip upcoming reservations feed, without the hotels (#1934)', async () => {
db.prepare("INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, 'Flight', 'flight', '2999-01-01T10:00:00')").run(tripId);
db.prepare("INSERT INTO reservations (trip_id, title, type, reservation_time) VALUES (?, 'Stay', 'hotel', '2999-01-01T09:00:00')").run(tripId);
const res = await request(server).get('/api/reservations/upcoming').set('Cookie', sessionCookie(1));
expect(res.status).toBe(200);
const titles = res.body.reservations.map((r: { title: string }) => r.title);
expect(titles).toContain('Flight');
expect(titles).not.toContain('Stay');
});
it('404 when trip not accessible (reservations)', async () => {
canAccessTrip.mockReturnValue(undefined);
const res = await request(server).get(`/api/trips/${tripId}/reservations`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('201 create reservation (real insert + booking notification), 400 without title', async () => {
const ok = await request(server)
.post(`/api/trips/${tripId}/reservations`)
.set('Cookie', sessionCookie(1))
.send({ title: 'Hotel' });
expect(ok.status).toBe(201);
expect(ok.body.reservation).toMatchObject({ title: 'Hotel', type: 'other', status: 'pending' });
expect(db.prepare('SELECT title FROM reservations WHERE id = ?').get(ok.body.reservation.id)).toEqual({ title: 'Hotel' });
// The fire-and-forget booking notification reaches the (mocked) notification service.
await vi.waitFor(() => expect(notificationSend).toHaveBeenCalled());
expect(notificationSend).toHaveBeenCalledWith(expect.objectContaining({ event: 'booking_change', actorId: 1 }));
const bad = await request(server).post(`/api/trips/${tripId}/reservations`).set('Cookie', sessionCookie(1)).send({});
expect(bad.status).toBe(400);
expect(bad.body.error).toContain('title');
});
it('200 list accommodations + 201 create (real insert + auto hotel reservation), 404 on bad refs', async () => {
const placeId = Number(db.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(tripId, 'Grand Hotel').lastInsertRowid);
const dayId = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, 1, ?)').run(tripId, '2026-03-01').lastInsertRowid);
const create = await request(server)
.post(`/api/trips/${tripId}/accommodations`)
.set('Cookie', sessionCookie(1))
.send({ place_id: placeId, start_day_id: dayId, end_day_id: dayId, check_in: '15:00' });
expect(create.status).toBe(201);
expect(create.body.accommodation).toMatchObject({ place_id: placeId, start_day_id: dayId, end_day_id: dayId, place_name: 'Grand Hotel' });
// The partner hotel reservation is auto-created by the real DaysService SQL.
const linked = db.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(create.body.accommodation.id) as { type: string; status: string };
expect(linked).toMatchObject({ type: 'hotel', status: 'confirmed' });
const list = await request(server).get(`/api/trips/${tripId}/accommodations`).set('Cookie', sessionCookie(1));
expect(list.status).toBe(200);
expect(list.body.accommodations).toHaveLength(1);
expect(list.body.accommodations[0]).toMatchObject({ id: create.body.accommodation.id, place_name: 'Grand Hotel' });
const badRefs = await request(server)
.post(`/api/trips/${tripId}/accommodations`)
.set('Cookie', sessionCookie(1))
.send({ place_id: 99999, start_day_id: dayId, end_day_id: dayId });
expect(badRefs.status).toBe(404);
expect(badRefs.body).toEqual({ error: 'Place not found' });
});
it('201 create also puts the place on its check-in day, and the delete takes that stop back', async () => {
// The road-trip view builds its stops from day_assignments and only looks the stay
// up afterwards, so a booking without one never reaches the route: the complaint
// was having to enter the same hotel a second time as an ordinary place.
const placeId = Number(db.prepare('INSERT INTO places (trip_id, name) VALUES (?, ?)').run(tripId, 'Hotel Adlon').lastInsertRowid);
const dayId = Number(db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, 4, ?)').run(tripId, '2026-03-04').lastInsertRowid);
const create = await request(server)
.post(`/api/trips/${tripId}/accommodations`)
.set('Cookie', sessionCookie(1))
.send({ place_id: placeId, start_day_id: dayId, end_day_id: dayId });
expect(create.status).toBe(201);
// In the answer, not only on the socket: the broadcast skips the sender.
expect(create.body.assignment).toMatchObject({ day_id: dayId, place_id: placeId });
expect(db.prepare('SELECT stop_type FROM places WHERE id = ?').get(placeId)).toMatchObject({ stop_type: 'hotel' });
const del = await request(server)
.delete(`/api/trips/${tripId}/accommodations/${create.body.accommodation.id}`)
.set('Cookie', sessionCookie(1));
expect(del.status).toBe(200);
expect(del.body.removedAssignments).toEqual([{ id: create.body.assignment.id, dayId }]);
expect(db.prepare('SELECT id FROM day_assignments WHERE day_id = ?').all(dayId)).toEqual([]);
});
it('404 when trip not accessible (accommodations)', async () => {
canAccessTrip.mockReturnValue(undefined);
const res = await request(server).get(`/api/trips/${tripId}/accommodations`).set('Cookie', sessionCookie(1));
expect(res.status).toBe(404);
expect(res.body).toEqual({ error: 'Trip not found' });
});
it('400 accommodation create without refs', async () => {
const res = await request(server)
.post(`/api/trips/${tripId}/accommodations`)
.set('Cookie', sessionCookie(1))
.send({ place_id: 2 });
expect(res.status).toBe(400);
expect(res.body).toEqual({ error: 'place_id, start_day_id, and end_day_id are required' });
});
// The per-day branch of updatePositions used to bind the caller's ids straight
// into reservation_day_positions without ever looking at the tripId it was
// handed, so a request authorised on your own trip could rewrite the ordering
// of someone else's. The legacy branch three lines below it always scoped by
// trip_id — these pin that the two behave the same way now.
describe('positions are confined to the trip in the URL', () => {
let foreignTripId: number;
let foreignReservationId: number;
let foreignDayId: number;
let ownDayId: number;
// Days are unique per (trip_id, day_number) and this block seeds a fresh pair
// for every case, so the numbers have to keep climbing.
let dayNumber = 100;
beforeEach(() => {
db.prepare(
"INSERT OR IGNORE INTO users (id, username, email, password_hash, role, password_version) VALUES (2, 'victim', 'victim@example.test', 'x', 'user', 0)",
).run();
foreignTripId = Number(db.prepare("INSERT INTO trips (user_id, title) VALUES (2, 'Someone else')").run().lastInsertRowid);
foreignReservationId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Secret', 'other')").run(foreignTripId).lastInsertRowid);
dayNumber += 1;
foreignDayId = Number(db.prepare("INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, '2026-05-01')").run(foreignTripId, dayNumber).lastInsertRowid);
ownDayId = Number(db.prepare("INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, '2026-05-01')").run(tripId, dayNumber).lastInsertRowid);
});
function positionRow(reservationId: number, dayId: number) {
return db.prepare('SELECT position FROM reservation_day_positions WHERE reservation_id = ? AND day_id = ?').get(reservationId, dayId);
}
it('writes nothing when both ids belong to another trip', async () => {
const res = await request(server)
.put(`/api/trips/${tripId}/reservations/positions`)
.set('Cookie', sessionCookie(1))
.send({ positions: [{ id: foreignReservationId, day_plan_position: 999 }], day_id: foreignDayId });
expect(res.status).toBe(200);
expect(positionRow(foreignReservationId, foreignDayId)).toBeUndefined();
});
it('writes nothing when only the reservation is foreign', async () => {
const res = await request(server)
.put(`/api/trips/${tripId}/reservations/positions`)
.set('Cookie', sessionCookie(1))
.send({ positions: [{ id: foreignReservationId, day_plan_position: 5 }], day_id: ownDayId });
expect(res.status).toBe(200);
expect(positionRow(foreignReservationId, ownDayId)).toBeUndefined();
});
it('writes nothing when only the day is foreign', async () => {
const ownReservationId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Mine', 'other')").run(tripId).lastInsertRowid);
const res = await request(server)
.put(`/api/trips/${tripId}/reservations/positions`)
.set('Cookie', sessionCookie(1))
.send({ positions: [{ id: ownReservationId, day_plan_position: 5 }], day_id: foreignDayId });
expect(res.status).toBe(200);
expect(positionRow(ownReservationId, foreignDayId)).toBeUndefined();
});
it('still stores a position when both ids are on the trip', async () => {
const ownReservationId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Mine', 'other')").run(tripId).lastInsertRowid);
const res = await request(server)
.put(`/api/trips/${tripId}/reservations/positions`)
.set('Cookie', sessionCookie(1))
.send({ positions: [{ id: ownReservationId, day_plan_position: 3 }], day_id: ownDayId });
expect(res.status).toBe(200);
expect(positionRow(ownReservationId, ownDayId)).toEqual({ position: 3 });
});
// An id that exists nowhere used to raise a foreign-key error, which the
// exception filter turned into a 500 — telling the caller apart from the
// 200 a real id returns, for any id on the instance.
it('answers a nonexistent reservation the same way it answers a foreign one', async () => {
const res = await request(server)
.put(`/api/trips/${tripId}/reservations/positions`)
.set('Cookie', sessionCookie(1))
.send({ positions: [{ id: 999999, day_plan_position: 1 }], day_id: ownDayId });
expect(res.status).toBe(200);
});
it('does not fall over when day_plan_position is omitted', async () => {
const ownReservationId = Number(db.prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Mine', 'other')").run(tripId).lastInsertRowid);
const res = await request(server)
.put(`/api/trips/${tripId}/reservations/positions`)
.set('Cookie', sessionCookie(1))
.send({ positions: [{ id: ownReservationId }], day_id: ownDayId });
expect(res.status).toBe(200);
});
});
});