Files
TREK/server/tests/unit/nest/reservations.controller.test.ts
T
Maurice 8864edda18 feat(budget): link existing expenses to bookings and places, several per booking
Discussion #2084: a cost bought later (the seat upgrade for a flight) could
not be tied to the booking it belongs to, and a booking held one expense.

- The Costs block of the booking, transport and place forms lists every
  linked expense with edit, unlink (the expense stays in Costs) and delete,
  and offers the expenses that belong nowhere yet under Link existing
  expense. The phone sheets get the same block in their own design, and
  their Files row lists the attached files and links an existing one.
- PUT /budget/:id, update_budget_item (MCP) and costs.update (plugin RPC)
  take reservation_id / place_id; create and update refuse an id from
  another trip on all three paths.
- The booking's mirrored price is the sum of its expenses when they share
  one currency (none counts as the trip's), else the first one, with that
  currency, and follows every change to a linked expense. The booking
  form's price field leaves a booking with several expenses alone.
- Deleting a booking deletes all of its linked expenses and broadcasts each
  one, also from the MCP delete tools.
- Cards show the price with its currency.
- Booking and transport dialogs: Booking Type and Travelers as dropdowns at
  the top, the URL beside Files (transports gain the URL field). The row is
  one shared component; linking and unlinking files go through the store.

Help center pictures and texts for the booking, transport and expense
guides are redone in all 27 languages, five Costs labels that were still
English in 16 languages are translated, and the wiki follows.
2026-09-25 15:34:32 +02:00

220 lines
12 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { ReservationsController } from '../../../src/nest/reservations/reservations.controller';
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { AirtrailLinkService } from '../../../src/nest/integrations/airtrail-link.service';
import type { User } from '../../../src/types';
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const trip = { id: 5, user_id: 1 };
// Fire-and-forget write-back trigger, injected since airtrail.bridge died.
const airtrailLink = { pushReservationToAirtrail: vi.fn().mockResolvedValue(undefined) } as unknown as AirtrailLinkService;
function makeService(overrides: Partial<ReservationsService> = {}): ReservationsService {
return {
verifyTripAccess: vi.fn().mockReturnValue(trip),
canEdit: vi.fn().mockReturnValue(true),
referencesOutsideTrip: vi.fn().mockReturnValue([]),
unresolvedReferences: vi.fn().mockReturnValue([]),
broadcast: vi.fn(),
syncBudgetOnCreate: vi.fn(),
syncBudgetOnUpdate: vi.fn(),
notifyBookingChange: vi.fn(),
...overrides,
} as unknown as ReservationsService;
}
function thrown(fn: () => unknown): { status: number; body: unknown } {
try { 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('ReservationsController (parity with the legacy /api/trips/:tripId/reservations route)', () => {
it('GET / returns reservations', () => {
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<ReservationsService>);
expect(new ReservationsController(svc, airtrailLink).list(user, '5')).toEqual({ reservations: [{ id: 1 }] });
});
describe('POST /', () => {
// The bespoke 'Title is required' 400 moved to the global ZodValidationPipe
// (ReservationCreateDto) — covered by the e2e suite.
it('creates, runs budget sync, broadcasts accommodation + reservation, notifies', () => {
const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true });
const broadcast = vi.fn(); const syncBudgetOnCreate = vi.fn(); const notifyBookingChange = vi.fn();
const svc = makeService({ create, broadcast, syncBudgetOnCreate, notifyBookingChange } as Partial<ReservationsService>);
const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } };
expect(new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 } });
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock');
expect(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Hotel', 'lodging', { total_price: 200 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock');
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging');
});
it('400s on a body id belonging to another trip, without writing', () => {
const create = vi.fn();
const svc = makeService({
create,
referencesOutsideTrip: vi.fn().mockReturnValue(['accommodation_id']),
} as Partial<ReservationsService>);
const body = { title: 'Hotel', accommodation_id: 4711 };
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body)))
.toEqual({ status: 400, body: { error: 'Not part of this trip: accommodation_id' } });
expect(create).not.toHaveBeenCalled();
});
it('400s on a body id that exists nowhere, in its own words, without writing', () => {
const create = vi.fn();
const svc = makeService({
create,
unresolvedReferences: vi.fn().mockReturnValue(['place_id']),
} as Partial<ReservationsService>);
const body = { title: 'Hotel', place_id: 4711 };
// Not 'Not part of this trip': an id that is part of nothing would send
// the caller looking for it on another trip.
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body)))
.toEqual({ status: 400, body: { error: 'Unknown reference: place_id' } });
expect(create).not.toHaveBeenCalled();
});
it('answers a foreign id with the older message when it is both', () => {
const svc = makeService({
create: vi.fn(),
referencesOutsideTrip: vi.fn().mockReturnValue(['place_id']),
unresolvedReferences: vi.fn().mockReturnValue(['place_id']),
} as Partial<ReservationsService>);
expect(thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', { title: 'Hotel', place_id: 4711 })))
.toEqual({ status: 400, body: { error: 'Not part of this trip: place_id' } });
});
});
describe('PUT /positions', () => {
// The 'positions must be an array' 400 moved to the global
// ZodValidationPipe (ReservationPositionsDto).
it('updates positions and broadcasts', () => {
const updatePositions = vi.fn(); const broadcast = vi.fn();
const svc = makeService({ updatePositions, broadcast } as Partial<ReservationsService>);
const positions = [{ id: 1, day_plan_position: 0 }];
expect(new ReservationsController(svc, airtrailLink).updatePositions(user, '5', { positions, day_id: 3 }, 'sock')).toEqual({ success: true });
expect(updatePositions).toHaveBeenCalledWith('5', positions, 3);
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:positions', { positions, day_id: 3 }, 'sock');
});
});
describe('PUT /:id', () => {
it('404 when the reservation is missing', () => {
const svc = makeService({ getReservation: vi.fn().mockReturnValue(undefined) } as Partial<ReservationsService>);
expect(thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { title: 'X' }))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
});
it('updates, syncs budget with current fallbacks, broadcasts + notifies', () => {
const getReservation = vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' });
const update = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationChanged: true });
const broadcast = vi.fn(); const syncBudgetOnUpdate = vi.fn(); const notifyBookingChange = vi.fn();
const svc = makeService({ getReservation, update, broadcast, syncBudgetOnUpdate, notifyBookingChange } as Partial<ReservationsService>);
new ReservationsController(svc, airtrailLink).update(user, '5', '9', { create_budget_entry: { total_price: 50 } }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', {}, 'sock');
expect(syncBudgetOnUpdate).toHaveBeenCalledWith('5', '9', '', undefined, 'Old', 'lodging', { total_price: 50 }, 'sock');
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Old', 'lodging');
});
it('400s on a body id belonging to another trip, without writing', () => {
const update = vi.fn();
const svc = makeService({
getReservation: vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' }),
update,
referencesOutsideTrip: vi.fn().mockReturnValue(['day_id', 'place_id']),
} as Partial<ReservationsService>);
expect(thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { day_id: 1, place_id: 2 })))
.toEqual({ status: 400, body: { error: 'Not part of this trip: day_id, place_id' } });
expect(update).not.toHaveBeenCalled();
});
it('400s on a body id that exists nowhere, without writing', () => {
const update = vi.fn();
const svc = makeService({
getReservation: vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' }),
update,
unresolvedReferences: vi.fn().mockReturnValue(['day_id', 'place_id']),
} as Partial<ReservationsService>);
// The reporter's request: a foreign-key error used to reach the caller as
// a bare 500 here.
expect(thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { day_id: 1, place_id: 2 })))
.toEqual({ status: 400, body: { error: 'Unknown reference: day_id, place_id' } });
expect(update).not.toHaveBeenCalled();
});
});
describe('PUT /:id/travelers', () => {
// The 'user_ids must be an array' 400 moved to the global
// ZodValidationPipe (ReservationTravelersDto).
it('404 when the reservation is off-trip / missing', () => {
const svc = makeService({ setTravelers: vi.fn().mockReturnValue(null) } as Partial<ReservationsService>);
expect(thrown(() => new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [1] }))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
});
it('assigns travelers, broadcasts, and returns { travelers, reservation }', () => {
const travelers = [{ user_id: 2, username: 'Sam', avatar: null, is_guest: 0 }];
const reservation = { id: 9, travelers };
const setTravelers = vi.fn().mockReturnValue({ travelers, reservation });
const broadcast = vi.fn();
const svc = makeService({ setTravelers, broadcast } as Partial<ReservationsService>);
expect(new ReservationsController(svc, airtrailLink).updateTravelers(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({ travelers, reservation });
expect(setTravelers).toHaveBeenCalledWith('9', '5', [2]);
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:travelers-updated', { reservationId: 9, travelers }, 'sock');
});
});
describe('DELETE /:id', () => {
it('404 when nothing deleted', () => {
const broadcast = vi.fn();
const svc = makeService({
remove: vi.fn().mockReturnValue({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] }),
broadcast,
} as Partial<ReservationsService>);
expect(thrown(() => new ReservationsController(svc, airtrailLink).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
expect(broadcast).not.toHaveBeenCalled();
});
it('broadcasts the accommodation + budget cascade then reservation:deleted', () => {
const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: 3 }, accommodationDeleted: true, deletedBudgetItemId: 7, deletedBudgetItemIds: [7] });
const broadcast = vi.fn(); const notifyBookingChange = vi.fn();
const svc = makeService({ remove, broadcast, notifyBookingChange } as Partial<ReservationsService>);
expect(new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock')).toEqual({ success: true });
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 3 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 9 }, 'sock');
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging');
});
it('announces every expense the booking took with it, before the booking itself (#2084)', () => {
const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Flight', type: 'flight', accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: 7, deletedBudgetItemIds: [7, 8] });
const broadcast = vi.fn();
const svc = makeService({ remove, broadcast } as Partial<ReservationsService>);
new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock');
expect(broadcast.mock.calls).toEqual([
['5', 'budget:deleted', { itemId: 7 }, 'sock'],
['5', 'budget:deleted', { itemId: 8 }, 'sock'],
['5', 'reservation:deleted', { reservationId: 9 }, 'sock'],
]);
});
it('announces no expense when the booking carried none', () => {
const remove = vi.fn().mockReturnValue({ deleted: { id: 9, title: 'Museum', type: 'activity', accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [] });
const broadcast = vi.fn();
const svc = makeService({ remove, broadcast } as Partial<ReservationsService>);
new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock');
expect(broadcast.mock.calls).toEqual([['5', 'reservation:deleted', { reservationId: 9 }, 'sock']]);
});
});
});