import type { AirtrailLinkService } from '../../../src/nest/integrations/airtrail-link.service'; import { ReservationsController } from '../../../src/nest/reservations/reservations.controller'; import type { ReservationsService } from '../../../src/nest/reservations/reservations.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 = { 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 { return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), referencesOutsideTrip: vi.fn().mockReturnValue([]), unresolvedReferences: vi.fn().mockReturnValue([]), broadcast: vi.fn(), announceCost: vi.fn(), notifyBookingChange: vi.fn(), // Hands the entry back as it came: what freezing does to it is the service's test. withFrozenRate: vi.fn(async (_tripId: string, entry: unknown) => entry), ...overrides, } as unknown as ReservationsService; } async function thrown(fn: () => 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('ReservationsController (parity with the legacy /api/trips/:tripId/reservations route)', () => { it('GET / returns reservations', async () => { const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial); expect(await 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 the booking and its cost in one write, broadcasts accommodation, cost and reservation in order, notifies', async () => { const costEvents = [{ event: 'budget:created', payload: { item: { id: 3 } } }]; const createWithCost = vi .fn() .mockReturnValue({ reservation: { id: 9 }, accommodationCreated: true, costEvents }); const order: string[] = []; const broadcast = vi.fn((_tripId: string, event: string) => { order.push(event); }); const announceCost = vi.fn(() => { order.push('cost'); }); const notifyBookingChange = vi.fn(); const svc = makeService({ createWithCost, broadcast, announceCost, notifyBookingChange, } as Partial); const body = { title: 'Hotel', type: 'lodging', create_budget_entry: { total_price: 200 } }; expect(await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock')).toEqual({ reservation: { id: 9 }, }); expect(createWithCost).toHaveBeenCalledWith('5', body, { total_price: 200 }); expect(announceCost).toHaveBeenCalledWith('5', costEvents, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', {}, 'sock'); expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', { reservation: { id: 9 } }, 'sock'); expect(order).toEqual(['accommodation:created', 'cost', 'reservation:created']); expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging'); }); it('400s on a body id belonging to another trip, without writing', async () => { const createWithCost = vi.fn(); const svc = makeService({ createWithCost, referencesOutsideTrip: vi.fn().mockReturnValue(['accommodation_id']), } as Partial); const body = { title: 'Hotel', accommodation_id: 4711 }; expect(await thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))).toEqual({ status: 400, body: { error: 'Not part of this trip: accommodation_id' }, }); expect(createWithCost).not.toHaveBeenCalled(); }); it('400s on a body id that exists nowhere, in its own words, without writing', async () => { const createWithCost = vi.fn(); const svc = makeService({ createWithCost, unresolvedReferences: vi.fn().mockReturnValue(['place_id']), } as Partial); 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(await thrown(() => new ReservationsController(svc, airtrailLink).create(user, '5', body))).toEqual({ status: 400, body: { error: 'Unknown reference: place_id' }, }); expect(createWithCost).not.toHaveBeenCalled(); }); it('answers a foreign id with the older message when it is both', async () => { const svc = makeService({ createWithCost: vi.fn(), referencesOutsideTrip: vi.fn().mockReturnValue(['place_id']), unresolvedReferences: vi.fn().mockReturnValue(['place_id']), } as Partial); expect( await 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' } }); }); // #2525: an imported booking quoted in dollars. The price has to reach the linked // cost in dollars, at a rate frozen before anything is written. it('hands the write the entry with its currency and frozen rate', async () => { const createWithCost = vi .fn() .mockReturnValue({ reservation: { id: 9 }, accommodationCreated: false, costEvents: [] }); const withFrozenRate = vi.fn(async () => { expect(createWithCost).not.toHaveBeenCalled(); return { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }; }); const svc = makeService({ createWithCost, withFrozenRate } as Partial); const body = { title: 'Aparthotel Silver', type: 'hotel', create_budget_entry: { total_price: 801.76, currency: 'usd' }, }; await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock'); expect(withFrozenRate).toHaveBeenCalledWith('5', { total_price: 801.76, currency: 'usd' }); expect(createWithCost).toHaveBeenCalledWith('5', body, { total_price: 801.76, currency: 'USD', exchange_rate: 1.17, }); }); }); describe('PUT /positions', () => { // The 'positions must be an array' 400 moved to the global // ZodValidationPipe (ReservationPositionsDto). it('updates positions and broadcasts', async () => { const updatePositions = vi.fn(); const broadcast = vi.fn(); const svc = makeService({ updatePositions, broadcast } as Partial); const positions = [{ id: 1, day_plan_position: 0 }]; expect( await 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', async () => { const svc = makeService({ getReservation: vi.fn().mockReturnValue(undefined) } as Partial); expect( await thrown(() => new ReservationsController(svc, airtrailLink).update(user, '5', '9', { title: 'X' })), ).toEqual({ status: 404, body: { error: 'Reservation not found' } }); }); it('updates the booking and its cost in one write, broadcasts + notifies with current fallbacks', async () => { const current = { title: 'Old', type: 'lodging' }; const getReservation = vi.fn().mockReturnValue(current); const costEvents = [{ event: 'budget:updated', payload: { item: { id: 3 } } }]; const updateWithCost = vi .fn() .mockReturnValue({ reservation: { id: 9 }, accommodationChanged: true, costEvents }); const broadcast = vi.fn(); const announceCost = vi.fn(); const notifyBookingChange = vi.fn(); const svc = makeService({ getReservation, updateWithCost, broadcast, announceCost, notifyBookingChange, } as Partial); const body = { create_budget_entry: { total_price: 50 } }; await new ReservationsController(svc, airtrailLink).update(user, '5', '9', body, 'sock'); expect(updateWithCost).toHaveBeenCalledWith('9', '5', body, current, { total_price: 50 }); expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', {}, 'sock'); expect(announceCost).toHaveBeenCalledWith('5', costEvents, 'sock'); expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Old', 'lodging'); }); it('400s on a body id belonging to another trip, without writing', async () => { const update = vi.fn(); const svc = makeService({ getReservation: vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' }), updateWithCost: update, referencesOutsideTrip: vi.fn().mockReturnValue(['day_id', 'place_id']), } as Partial); expect( await 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', async () => { const update = vi.fn(); const svc = makeService({ getReservation: vi.fn().mockReturnValue({ title: 'Old', type: 'lodging' }), updateWithCost: update, unresolvedReferences: vi.fn().mockReturnValue(['day_id', 'place_id']), } as Partial); // The reporter's request: a foreign-key error used to reach the caller as // a bare 500 here. expect( await 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', async () => { const svc = makeService({ setTravelers: vi.fn().mockReturnValue(null) } as Partial); expect( await 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 }', async () => { 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); expect( await 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', async () => { const broadcast = vi.fn(); const svc = makeService({ remove: vi.fn().mockReturnValue({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null, deletedBudgetItemIds: [], }), broadcast, } as Partial); expect(await 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', async () => { 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); expect(await 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)', async () => { 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); await 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', async () => { 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); await new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock'); expect(broadcast.mock.calls).toEqual([['5', 'reservation:deleted', { reservationId: 9 }, 'sock']]); }); }); });