Files
TREK/server/tests/e2e/reservations.e2e.test.ts
T
Maurice 681fe1fee3 fix(dashboard): keep hotels out of the upcoming reservations widget
A stay covers a range rather than a moment, so one week in a hotel would hold a
slot in a six-entry widget for the whole week and push out the bookings that
actually happen on a day. The accommodation belongs to the day plan, which shows
it across its span.

They never worked here either, which is what the report is about. A hotel keeps
its dates on the linked stay, not on the reservation, so the two ways one can
exist behaved differently:

The booking form writes reservation_time = NULL and sends no day_id, so a
form-created hotel matched neither arm of the date test and was silently absent.
That is the "incomplete" half.

The accommodation panel auto-creates its booking with day_id and with the start
day's date stamped into reservation_time. That row did show, but updateAccommodation
syncs only metadata and the confirmation number to its linked bookings, so moving
the stay left the stamp untouched and the widget kept the old date. That is the
"no effect when I change the date" half.

The type test is NULL-safe: the column defaults to 'other' but nothing stops a
NULL, and a bare `type != 'hotel'` evaluates to NULL there and would have dropped
those rows from the widget instead.

Closes #1934
2026-08-14 13:34:13 +02:00

189 lines
9.5 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('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' });
});
});