Files
TREK/server/tests/unit/nest/accommodations.controller.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

271 lines
13 KiB
TypeScript

import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller';
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
import type { User } from '../../../src/types';
import { HttpException } from '@nestjs/common';
import { describe, it, expect, vi } from 'vitest';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const trip = { user_id: 1 };
const refs = { place_id: 2, start_day_id: 10, end_day_id: 11 };
/** A write that left the day plan alone, which is what most of these cases are about. */
const noMirror = { created: null, removed: [], stamped: null };
/**
* The real stay use cases (createStay/updateStay/deleteStay and the REST writer)
* over stubbed persistence, so these cases pin what the route answers and sends,
* not how the controller calls into the service. The override names are the
* route-facing ones the cases always used: `get`, `create`, `update`, `remove`,
* `validateRefs` and `broadcast` (the realtime transport).
*/
function makeService(overrides: object = {}): AccommodationsService {
const {
broadcast = vi.fn(),
validateRefs,
get,
create,
update,
remove,
...rest
} = overrides as Record<string, unknown>;
return Object.assign(Object.create(AccommodationsService.prototype) as AccommodationsService, {
trips: { findAccessible: vi.fn().mockResolvedValue(trip) },
permissions: { checkPermission: vi.fn().mockResolvedValue(true) },
realtime: { broadcast },
announceMirror: vi.fn(),
validateAccommodationRefs: validateRefs ?? vi.fn().mockReturnValue([]),
...(get ? { getAccommodation: get } : {}),
...(create ? { createAccommodation: create } : {}),
...(update ? { updateAccommodation: update } : {}),
...(remove ? { deleteAccommodation: remove } : {}),
...rest,
});
}
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
try {
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
return { status: e.getStatus(), body: e.getResponse() };
}
throw new Error('expected throw');
}
describe('AccommodationsController (parity with the legacy accommodations sub-router)', () => {
it('GET / lists (no permission gate)', async () => {
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AccommodationsService>);
expect(await new AccommodationsController(svc).list(user, '5')).toEqual({ accommodations: [{ id: 1 }] });
});
describe('POST /', () => {
it('400 when refs are missing', async () => {
expect(
await thrownAsync(() => new AccommodationsController(makeService()).create(user, '5', { place_id: 2 })),
).toEqual({
status: 400,
body: { error: 'place_id, start_day_id, and end_day_id are required' },
});
});
it('404 with the first validateRefs error message', async () => {
const svc = makeService({
validateRefs: vi.fn().mockReturnValue([{ field: 'place_id', message: 'Place not found' }]),
} as Partial<AccommodationsService>);
expect(await thrownAsync(() => new AccommodationsController(svc).create(user, '5', refs))).toEqual({
status: 404,
body: { error: 'Place not found' },
});
});
it('creates and emits accommodation:created + reservation:created', async () => {
const create = vi.fn().mockReturnValue({ accommodation: { id: 9 }, mirror: noMirror });
const broadcast = vi.fn();
const svc = makeService({ create, broadcast } as Partial<AccommodationsService>);
expect(await new AccommodationsController(svc).create(user, '5', refs, 'sock')).toEqual({
accommodation: { id: 9 },
assignment: null,
});
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', { accommodation: { id: 9 } }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', {}, 'sock');
});
it('ACC-CTL-001 hands the day stop back in the answer and announces it', async () => {
// The stop reaches the session that booked the night over the socket as well;
// the answer is what it has left when that socket is down.
const stop = { id: 77, day_id: 10 };
const create = vi
.fn()
.mockReturnValue({ accommodation: { id: 9 }, mirror: { created: stop, removed: [], stamped: null } });
const announceMirror = vi.fn();
const svc = makeService({ create, announceMirror } as Partial<AccommodationsService>);
expect(await new AccommodationsController(svc).create(user, '5', refs, 'sock')).toEqual({
accommodation: { id: 9 },
assignment: stop,
});
expect(announceMirror).toHaveBeenCalledWith(
'5',
{ created: stop, removed: [], stamped: null },
expect.any(Function),
'sock',
);
});
});
describe('PUT /:id', () => {
it('404 when the accommodation is missing', async () => {
const svc = makeService({ get: vi.fn().mockReturnValue(undefined) } as Partial<AccommodationsService>);
expect(await thrownAsync(() => new AccommodationsController(svc).update(user, '5', '9', refs))).toEqual({
status: 404,
body: { error: 'Accommodation not found' },
});
});
it('updates and broadcasts', async () => {
const get = vi.fn().mockReturnValue({ id: 9 });
const update = vi.fn().mockReturnValue({ accommodation: { id: 9, notes: 'x' }, mirror: noMirror });
const broadcast = vi.fn();
const svc = makeService({ get, update, broadcast } as Partial<AccommodationsService>);
expect(await new AccommodationsController(svc).update(user, '5', '9', refs, 'sock')).toEqual({
accommodation: { id: 9, notes: 'x' },
assignment: null,
removedAssignments: [],
});
expect(broadcast).toHaveBeenCalledWith(
'5',
'accommodation:updated',
{ accommodation: { id: 9, notes: 'x' } },
'sock',
);
});
it('ACC-CTL-002 a booking moved to another day reports the stop it took with it', async () => {
const get = vi.fn().mockReturnValue({ id: 9 });
const mirror = { created: { id: 78, day_id: 11 }, removed: [{ id: 77, dayId: 10 }], stamped: null };
const update = vi.fn().mockReturnValue({ accommodation: { id: 9 }, mirror });
const announceMirror = vi.fn();
const svc = makeService({ get, update, announceMirror } as Partial<AccommodationsService>);
expect(await new AccommodationsController(svc).update(user, '5', '9', refs, 'sock')).toEqual({
accommodation: { id: 9 },
assignment: mirror.created,
removedAssignments: mirror.removed,
});
expect(announceMirror).toHaveBeenCalledWith('5', mirror, expect.any(Function), 'sock');
});
it('ACC-CTL-003 404s a place or day that is not on this trip, before the write', async () => {
const get = vi.fn().mockReturnValue({ id: 9 });
const update = vi.fn();
const validateRefs = vi.fn().mockReturnValue([{ message: 'Place not found' }]);
const svc = makeService({ get, update, validateRefs } as Partial<AccommodationsService>);
expect(await thrownAsync(() => new AccommodationsController(svc).update(user, '5', '9', refs))).toEqual({
status: 404,
body: { error: 'Place not found' },
});
expect(update).not.toHaveBeenCalled();
});
it('ACC-CTL-004 the mirror sender is the same broadcast, reaching the sender too', async () => {
// announceMirror decides which events a stay write implies; the controller only
// hands it the door out. That door must not skip the sender's socket: the day's
// new order and its re-pinned vias are not in the answer, and a session that
// takes the stop from the answer alone keeps the stops behind it on their old
// numbers. The stop itself arriving twice is a duplicate the store drops.
const get = vi.fn().mockReturnValue({ id: 9 });
const mirror = { created: { id: 78, day_id: 11 }, removed: [{ id: 77, dayId: 10 }], stamped: null };
const update = vi.fn().mockReturnValue({ accommodation: { id: 9 }, mirror });
const broadcast = vi.fn();
type Send = (event: string, payload: unknown) => void;
const announceMirror = vi.fn(async (_tripId: string, m: typeof mirror, send: Send) => {
for (const stop of m.removed) send('assignment:deleted', { assignmentId: stop.id, dayId: stop.dayId });
if (m.created) send('assignment:created', { assignment: m.created });
send('assignment:reordered', { dayId: 11, orderedIds: [70, 78, 71] });
send('roadtripVia:changed', { dayId: 11, vias: [] });
});
const svc = makeService({ get, update, broadcast, announceMirror } as Partial<AccommodationsService>);
await new AccommodationsController(svc).update(user, '5', '9', refs, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:deleted', { assignmentId: 77, dayId: 10 }, undefined);
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: mirror.created }, undefined);
expect(broadcast).toHaveBeenCalledWith(
'5',
'assignment:reordered',
{ dayId: 11, orderedIds: [70, 78, 71] },
undefined,
);
expect(broadcast).toHaveBeenCalledWith('5', 'roadtripVia:changed', { dayId: 11, vias: [] }, undefined);
// The booking's own event is still the sender's echo, and still suppressed, and
// the journey reconcile behind announceMirror still gets the socket id (ACC-CTL-002).
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', { accommodation: { id: 9 } }, 'sock');
});
});
describe('DELETE /:id', () => {
it('404 when missing', async () => {
const svc = makeService({ get: vi.fn().mockReturnValue(undefined) } as Partial<AccommodationsService>);
expect(await thrownAsync(() => new AccommodationsController(svc).remove(user, '5', '9'))).toEqual({
status: 404,
body: { error: 'Accommodation not found' },
});
});
it('emits the linked reservation/budget cascade then accommodation:deleted', async () => {
const get = vi.fn().mockReturnValue({ id: 9 });
const remove = vi.fn().mockReturnValue({
linkedReservationId: 4,
deletedBudgetItemId: 7,
linkedReservationIds: [4],
deletedBudgetItemIds: [7],
mirror: noMirror,
});
const broadcast = vi.fn();
const svc = makeService({ get, remove, broadcast } as Partial<AccommodationsService>);
expect(await new AccommodationsController(svc).remove(user, '5', '9', 'sock')).toEqual({
success: true,
removedAssignments: [],
});
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 4 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 9 }, 'sock');
});
it('emits one event per booking when a stay carried more than one (#1869)', async () => {
const get = vi.fn().mockReturnValue({ id: 9 });
const remove = vi.fn().mockReturnValue({
linkedReservationId: 4,
deletedBudgetItemId: null,
linkedReservationIds: [4, 5],
deletedBudgetItemIds: [],
mirror: noMirror,
});
const broadcast = vi.fn();
const svc = makeService({ get, remove, broadcast } as Partial<AccommodationsService>);
await new AccommodationsController(svc).remove(user, '5', '9', 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 4 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 5 }, 'sock');
});
it('ACC-CTL-005 ?keepStop=true is what turns a night back into an ordinary pause', async () => {
// The road trip popup switching the night off: the booking goes, the stop it
// brought stays where it is in the drive. Nothing else may pass that flag, so
// only the literal string counts.
const get = vi.fn().mockReturnValue({ id: 9 });
const remove = vi.fn().mockReturnValue({
linkedReservationId: null,
deletedBudgetItemId: null,
linkedReservationIds: [],
deletedBudgetItemIds: [],
mirror: noMirror,
});
const svc = makeService({ get, remove } as Partial<AccommodationsService>);
const controller = new AccommodationsController(svc);
await controller.remove(user, '5', '9', 'sock', 'true');
expect(remove).toHaveBeenLastCalledWith('9', { keepStop: true });
await controller.remove(user, '5', '9', 'sock');
expect(remove).toHaveBeenLastCalledWith('9', { keepStop: false });
});
});
});