mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
06bee95ae3
A euro trip read in dollars books a bill entered in dollars at the rate of the day, from the euro's quote: 12,345.67 USD at 1.1398 is 10,831.44 EUR. The Costs screen and the settlement then turned those euros back into dollars with the dollar's own quote, 1 / 0.87732. Frankfurter rounds the two quotes separately, so they are not exact inverses, and the bill read $12,346.05 the moment it was saved, a $250.00 one $250.01. The error grows with the amount, which is why the large rows showed it first. Paying back what settle-up offered for such a bill left the trip open with the debt flipped the other way. Both sides now convert trip money to the display currency with the trip currency's own quote, the one every entry rate is frozen from. On the server, settlement() fetches that quote for the REST route and for the MCP get_settlement_summary tool, which now makes the same call, and a legacy transfer without a currency reads back through the same factor. On the client, useExchangeRates takes the trip currency as an anchor and converts through its cached quote, falling back to the display currency's while it is missing, so it works offline from cache and needs no change to the settlement response. The final budget builds each row from the unrounded trip amount, because a euro cent is worth more than a dollar cent and 10,831.44 EUR came back as 12,345.68; the balances and settle-up still net whole trip cents. A same-day bill in the display currency now reads exactly as typed on the row, the payer chip, the day header, the totals, Outstanding and the final budget, on both shells and on the shared trip page, and paying the amount settle-up offers leaves both balances at zero. When the rate really moved, the row keeps the value the totals count, with what was entered and the euros it was booked at underneath, "$801.76 -> 685,26 EUR". One helper builds that line for the desktop list, the phone list, payments, the final budget breakdown and the shared page; it prints nothing for an amount of 0 or a bill that reads as typed. The expense editors share one module for their preview. The split summary puts the share the row lends or borrows next to the entered one, "$400.88 -> $390.53 each", and an expense saved without a currency still opens in the trip currency. Both CSV exports add the amount in the trip currency when it differs from the display currency. Kept from before: the desktop totals recompute when rates arrive, a booking's linked cost is labelled in the trip currency, a legacy transfer is read in the display currency, the shared page reads the frozen rate in whole cents, a booking import keeps the currency it was quoted in at a rate frozen before the write (REST and plugin RPC), and the MCP trip summary, the budget-overview prompt and the per-person summary add every row up in trip cents at its frozen rate. The packing-list prompt reads the trip title directly instead of building the whole summary, which could wait on a rates fetch.
222 lines
13 KiB
TypeScript
222 lines
13 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(),
|
|
// 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;
|
|
}
|
|
|
|
/** The create route awaits the price's rate, so its 400s arrive as a rejection. */
|
|
async function rejected(promise: Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
|
try { await promise; } catch (err) {
|
|
expect(err).toBeInstanceOf(HttpException);
|
|
const e = err as HttpException;
|
|
return { status: e.getStatus(), body: e.getResponse() };
|
|
}
|
|
throw new Error('expected throw');
|
|
}
|
|
|
|
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', async () => {
|
|
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(await 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', async () => {
|
|
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(await rejected(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', async () => {
|
|
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(await rejected(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', async () => {
|
|
const svc = makeService({
|
|
create: vi.fn(),
|
|
referencesOutsideTrip: vi.fn().mockReturnValue(['place_id']),
|
|
unresolvedReferences: vi.fn().mockReturnValue(['place_id']),
|
|
} as Partial<ReservationsService>);
|
|
expect(await rejected(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 budget sync the entry with its currency and frozen rate', async () => {
|
|
const create = vi.fn().mockReturnValue({ reservation: { id: 9 }, accommodationCreated: false });
|
|
const syncBudgetOnCreate = vi.fn();
|
|
const withFrozenRate = vi.fn(async () => {
|
|
expect(create).not.toHaveBeenCalled();
|
|
return { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 };
|
|
});
|
|
const svc = makeService({ create, syncBudgetOnCreate, 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(syncBudgetOnCreate).toHaveBeenCalledWith('5', 9, 'Aparthotel Silver', 'hotel', { total_price: 801.76, currency: 'USD', exchange_rate: 1.17 }, 'sock');
|
|
});
|
|
});
|
|
|
|
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 svc = makeService({ remove: vi.fn().mockReturnValue({ deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null }) } as Partial<ReservationsService>);
|
|
expect(thrown(() => new ReservationsController(svc, airtrailLink).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Reservation not found' } });
|
|
});
|
|
|
|
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 });
|
|
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');
|
|
});
|
|
});
|
|
});
|