mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
5cbd9e9d36
A booking's create and update ran their own transaction, and the linked cost sync ran afterwards from the REST controller and the plugin RPC handler, so a failing cost write left the booking edited and its expense on the old category or price. MCP linked the cost in a second call of its own the same way. ReservationsService.createWithCost and updateWithCost now run the booking write and the cost sync in one transaction and hand the cost events back, so REST, MCP and the plugin RPC call the same method and broadcast only after the commit, in the order they did before. The cost sync keeps swallowing its own failures the way the REST route always has, so a cost that cannot be written still leaves the booking; MCP now behaves like REST there instead of failing the tool after the booking was already saved. The service sheds its inventory markers (RS1..RS54, plan history) to stay under its lint:size entry.
246 lines
15 KiB
TypeScript
246 lines
15 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(),
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<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(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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
// 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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
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<ReservationsService>);
|
|
await new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock');
|
|
expect(broadcast.mock.calls).toEqual([['5', 'reservation:deleted', { reservationId: 9 }, 'sock']]);
|
|
});
|
|
});
|
|
});
|