fix(realtime): booking writes share one use case on REST and MCP

This commit is contained in:
Maurice
2026-10-22 12:14:25 +02:00
parent 2563afa582
commit dbb70fc525
15 changed files with 948 additions and 398 deletions
@@ -34,7 +34,6 @@
"tests/unit/mcp/tools-transit.test.ts": 1,
"tests/unit/nest-rpc/plugin-guards.test.ts": 1,
"tests/unit/nest-rpc/rpc-kit/registry.service.test.ts": 3,
"tests/unit/nest/accommodations.event-parity.test.ts": 1,
"tests/unit/nest/accommodations.service.test.ts": 1,
"tests/unit/nest/addons.service.test.ts": 2,
"tests/unit/nest/admin.service.test.ts": 11,
@@ -0,0 +1,141 @@
import type { Reservation, User } from '../../types';
import { DomainError } from '../common/domain-error';
import { restTripWriter, type TripWriter } from '../common/trip-writer';
import { AirtrailLinkService } from '../integrations/airtrail-link.service';
import { RealtimeService } from '../realtime/realtime.service';
import {
ReservationsService,
type CostEvent,
type CreateReservationData,
type UpdateReservationData,
} from './reservations.service';
import { Injectable } from '@nestjs/common';
/** The linked cost a booking write takes, as ReservationsService.createWithCost reads it. */
type BudgetEntry = Parameters<ReservationsService['createWithCost']>[2];
/** The day plan order a reorder writes, as ReservationsService.updatePositions reads it. */
type Positions = Parameters<ReservationsService['updatePositions']>[1];
const bookingNotFound = (mcpMessage: string) => new DomainError(404, 'Reservation not found', { mcpMessage });
/**
* The booking writes REST and MCP share, one method per use case: the write,
* then every event it sends and the notice to fellow travellers, so both
* surfaces send the same things in the same order.
*
* Each runs after its caller checked the trip and the references (REST's
* TripAccessGuard with reservation_edit and the controller's foreign id check,
* the tool's own checks on MCP), writes in ReservationsService's transaction and
* sends once that committed. The day stop a hotel booking brings is announced by
* ReservationsService itself, before the events here, to every socket.
*
* MCP used to send these events from the tool and had drifted: no booking
* notice, no push of an edited AirTrail flight (the next pull reverted the
* edit), no `accommodation:deleted` when a transport still carried a stay, its
* own day key on `reservation:positions`, and the hotel link announced a stay it
* had not changed.
*/
@Injectable()
export class ReservationWritesService {
constructor(
private readonly reservations: ReservationsService,
private readonly airtrailLink: AirtrailLinkService,
private readonly realtime: RealtimeService,
) {}
/** The REST caller of a booking write: skips the sender's socket, as the routes always did. */
restWriter(tripId: string, user: User, socketId: string | undefined): TripWriter {
return restTripWriter(this.realtime, tripId, user, socketId);
}
/** A new booking with its linked cost, and the stay a hotel booking brings. */
async createBooking(tripId: string, data: CreateReservationData, entry: BudgetEntry, writer: TripWriter) {
const { reservation, accommodationCreated, costEvents } = await this.reservations.createWithCost(
tripId,
data,
entry,
);
if (accommodationCreated) writer.events.emit('accommodation:created', {});
this.sendCost(costEvents, writer);
writer.events.emit('reservation:created', { reservation });
await this.reservations.notifyBookingChange(tripId, writer.userId, data.title, data.type ?? '');
return { reservation };
}
/**
* An edit of a booking the caller already read (`current`), with the costs it
* re-files. A stay the edit wrote for the first time is `accommodation:created`,
* one it changed `accommodation:updated`. A linked AirTrail flight is pushed
* back to AirTrail, or the next pull would overwrite the edit.
*
* @txIndependent the booking write commits in one transaction; the AirTrail
* push after it is network I/O against another system and may detach the link
* on its own, so it never shares that transaction.
*/
async updateBooking(
id: string,
tripId: string,
data: UpdateReservationData,
current: Reservation,
entry: BudgetEntry,
writer: TripWriter,
) {
const { reservation, accommodationChanged, accommodationCreated, costEvents } =
await this.reservations.updateWithCost(id, tripId, data, current, entry);
if (accommodationCreated) writer.events.emit('accommodation:created', {});
else if (accommodationChanged) writer.events.emit('accommodation:updated', {});
this.sendCost(costEvents, writer);
writer.events.emit('reservation:updated', { reservation });
// Fire and forget, under the importer's credentials (#214).
if (reservation.external_source === 'airtrail' && reservation.sync_enabled) {
void this.airtrailLink.pushReservationToAirtrail(Number(reservation.id), Number(tripId)).catch(() => {});
}
await this.reservations.notifyBookingChange(
tripId,
writer.userId,
data.title || current.title,
data.type || current.type || '',
);
return { reservation };
}
/**
* A booking deleted with its stay and its linked costs. `mcpNotFound` is the
* wording the tool has always answered a missing booking with.
*/
async deleteBooking(
id: string | number,
tripId: string | number,
writer: TripWriter,
mcpNotFound = 'Reservation not found.',
) {
const { deleted, accommodationDeleted, deletedBudgetItemIds } = await this.reservations.remove(id, tripId);
if (!deleted) throw bookingNotFound(mcpNotFound);
if (accommodationDeleted)
writer.events.emit('accommodation:deleted', { accommodationId: deleted.accommodation_id });
for (const itemId of deletedBudgetItemIds) writer.events.emit('budget:deleted', { itemId });
writer.events.emit('reservation:deleted', { reservationId: Number(id) });
await this.reservations.notifyBookingChange(tripId, writer.userId, deleted.title, deleted.type || '');
return { success: true };
}
/** The order of the bookings in the day plan, of one day when `dayId` names it. */
async reorderBookings(
tripId: string | number,
positions: Positions,
dayId: number | string | null | undefined,
writer: TripWriter,
) {
await this.reservations.updatePositions(tripId, positions, dayId);
// REST's `day_id` is the canonical key. The MCP tool always named it `dayId`,
// so its event keeps that key next to it for a client that reads it.
writer.events.emit(
'reservation:positions',
writer.surface === 'mcp' ? { positions, day_id: dayId, dayId } : { positions, day_id: dayId },
);
}
private sendCost(events: readonly CostEvent[], writer: TripWriter): void {
for (const e of events) writer.events.emit(e.event, e.payload);
}
}
@@ -1,8 +1,8 @@
import type { User } from '../../types';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { AirtrailLinkService } from '../integrations/airtrail-link.service';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { ReservationWritesService } from './reservation-writes.service';
import {
ReservationCreateDto,
ReservationUpdateDto,
@@ -25,6 +25,8 @@ type ReservationBody = Record<string, unknown> & {
* trip access (404), 'reservation_edit' permission (403), create 201 / rest 200,
* the bespoke 404 bodies, the accommodation + budget side effects, the booking
* notifications, and all WebSocket broadcasts with the forwarded X-Socket-Id.
* Create, edit, delete and reorder run the use cases of ReservationWritesService,
* which the MCP tools share, so both surfaces send the same events.
* Invalid bodies (missing title, non-array positions/user_ids) now 400 through
* the global ZodValidationPipe envelope instead of the bespoke strings.
* /positions is declared before /:id so it wins over the param.
@@ -37,8 +39,7 @@ type ReservationBody = Record<string, unknown> & {
export class ReservationsController {
constructor(
private readonly reservations: ReservationsService,
// Injected from AirtrailCoreModule — the split that retired airtrail.bridge.
private readonly airtrailLink: AirtrailLinkService,
private readonly writes: ReservationWritesService,
) {}
@Get()
@@ -60,18 +61,12 @@ export class ReservationsController {
// at a rate frozen now (#2525).
const budgetEntry = await this.reservations.withFrozenRate(tripId, body.create_budget_entry);
// The booking and its linked cost are one write.
const { reservation, accommodationCreated, costEvents } = await this.reservations.createWithCost(
return this.writes.createBooking(
tripId,
body as never,
budgetEntry,
this.writes.restWriter(tripId, user, socketId),
);
if (accommodationCreated) {
this.reservations.broadcast(tripId, 'accommodation:created', {}, socketId);
}
this.reservations.announceCost(tripId, costEvents, socketId);
this.reservations.broadcast(tripId, 'reservation:created', { reservation }, socketId);
await this.reservations.notifyBookingChange(tripId, user.id, body.title, body.type ?? '');
return { reservation };
}
@RequirePermission('reservation_edit')
@@ -84,16 +79,11 @@ export class ReservationsController {
) {
// The legacy signature declares day_plan_position required, but the wire
// contract tolerates absent values (bind NULL) — see the shared schema.
await this.reservations.updatePositions(
await this.writes.reorderBookings(
tripId,
body.positions as { id: number; day_plan_position: number }[],
body.day_id,
);
this.reservations.broadcast(
tripId,
'reservation:positions',
{ positions: body.positions, day_id: body.day_id },
socketId,
this.writes.restWriter(tripId, user, socketId),
);
return { success: true };
}
@@ -113,27 +103,15 @@ export class ReservationsController {
throw new HttpException({ error: 'Reservation not found' }, 404);
}
await this.rejectForeignReferences(tripId, body);
// The booking and its linked cost are one write.
const { reservation, accommodationChanged, costEvents } = await this.reservations.updateWithCost(
// The booking and its linked cost are one write; a linked AirTrail flight is pushed back after it.
return this.writes.updateBooking(
id,
tripId,
body as never,
current,
body.create_budget_entry,
this.writes.restWriter(tripId, user, socketId),
);
if (accommodationChanged) {
this.reservations.broadcast(tripId, 'accommodation:updated', {}, socketId);
}
const cur = current as { title: string; type?: string };
this.reservations.announceCost(tripId, costEvents, socketId);
this.reservations.broadcast(tripId, 'reservation:updated', { reservation }, socketId);
// Push a locally-edited AirTrail flight back to AirTrail (fire-and-forget,
// under the importer's credentials — see airtrailSync). #214
if ((reservation as any)?.external_source === 'airtrail' && (reservation as any)?.sync_enabled) {
void this.airtrailLink.pushReservationToAirtrail(Number((reservation as any).id), Number(tripId)).catch(() => {});
}
await this.reservations.notifyBookingChange(tripId, user.id, body.title || cur.title, body.type || cur.type || '');
return { reservation };
}
@RequirePermission('reservation_edit')
@@ -166,24 +144,8 @@ export class ReservationsController {
@Param('id') id: string,
@Headers('x-socket-id') socketId?: string,
) {
const { deleted, accommodationDeleted, deletedBudgetItemIds } = await this.reservations.remove(id, tripId);
if (!deleted) {
throw new HttpException({ error: 'Reservation not found' }, 404);
}
if (accommodationDeleted) {
this.reservations.broadcast(
tripId,
'accommodation:deleted',
{ accommodationId: deleted.accommodation_id },
socketId,
);
}
for (const itemId of deletedBudgetItemIds) {
this.reservations.broadcast(tripId, 'budget:deleted', { itemId }, socketId);
}
this.reservations.broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, socketId);
await this.reservations.notifyBookingChange(tripId, user.id, deleted.title, deleted.type || '');
return { success: true };
// A missing booking answers { error: 'Reservation not found' } with 404.
return this.writes.deleteBooking(id, tripId, this.writes.restWriter(tripId, user, socketId));
}
/**
@@ -9,6 +9,7 @@ import { z } from 'zod';
import { BudgetService } from '../budget/budget.service';
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
import { ReservationsService } from './reservations.service';
import { ReservationWritesService } from './reservation-writes.service';
import { DaysService } from '../days/days.service';
import { findByIata } from '../airports/airports.data';
import type { EndpointInput } from './reservations.service';
@@ -330,6 +331,9 @@ export class ReservationsMcp {
// positionally.
private readonly assignments: AssignmentsService,
private readonly guards: McpToolGuardsService,
// The booking use cases the REST routes run too: the events, the booking
// notice and the AirTrail push leave from there, not from the tools.
private readonly writes: ReservationWritesService,
) {}
@Tool({
@@ -388,20 +392,13 @@ export class ReservationsMcp {
const metadata = price != null ? { price: String(price) } : undefined;
// The booking and its linked cost are one write, the same service path REST takes.
const { reservation, accommodationCreated, costEvents } = await this.reservations.createWithCost(String(tripId), {
// The booking and its linked cost are one write, the same use case REST runs.
const { reservation } = await this.writes.createBooking(String(tripId), {
title, type, reservation_time, reservation_end_time, url, location, confirmation_number,
notes, day_id, place_id, assignment_id,
create_accommodation: createAccommodation,
metadata,
}, linkedCost(price, budget_category || type));
if (accommodationCreated) {
this.guards.safeBroadcast(tripId, 'accommodation:created', {});
}
for (const { event, payload } of costEvents) this.guards.safeBroadcast(tripId, event, payload);
this.guards.safeBroadcast(tripId, 'reservation:created', { reservation });
}, linkedCost(price, budget_category || type), await this.guards.tripWriter(tripId, ctx.userId));
return ok({ reservation });
}
@@ -446,15 +443,12 @@ export class ReservationsMcp {
return errorResult('assignment_id does not belong to this trip.');
// The booking and the linked expenses a type change re-files are one write,
// the same service path REST and the plugin RPC take.
const { reservation, accommodationChanged, costEvents } = await this.reservations.updateWithCost(String(reservationId), String(tripId), {
// the same use case REST runs.
const { reservation } = await this.writes.updateBooking(String(reservationId), String(tripId), {
title, type, reservation_time, reservation_end_time, url, location, confirmation_number, notes, status,
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
}, existing, undefined);
if (accommodationChanged) this.guards.safeBroadcast(tripId, 'accommodation:updated', {});
for (const { event, payload } of costEvents) this.guards.safeBroadcast(tripId, event, payload);
this.guards.safeBroadcast(tripId, 'reservation:updated', { reservation });
}, existing, undefined, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ reservation });
}
@@ -471,14 +465,7 @@ export class ReservationsMcp {
async deleteReservation({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const { deleted, accommodationDeleted, deletedBudgetItemIds } = await this.reservations.remove(reservationId, tripId);
if (!deleted) return errorResult('Reservation not found.');
if (accommodationDeleted) {
this.guards.safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
}
for (const itemId of deletedBudgetItemIds) this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId });
this.guards.safeBroadcast(tripId, 'reservation:deleted', { reservationId });
return ok({ success: true });
return ok(await this.writes.deleteBooking(reservationId, tripId, await this.guards.tripWriter(tripId, ctx.userId)));
}
@Tool({
@@ -542,8 +529,7 @@ export class ReservationsMcp {
if (dayId && !(await this.days.getDay(dayId, tripId)))
return errorResult('dayId does not belong to this trip.');
await this.reservations.updatePositions(tripId, positions, dayId);
this.guards.safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
await this.writes.reorderBookings(tripId, positions, dayId, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ success: true });
}
@@ -582,16 +568,14 @@ export class ReservationsMcp {
if (!(await this.days.getDay(end_day_id, tripId)))
return errorResult('end_day_id does not belong to this trip.');
const isNewAccommodation = !current.accommodation_id;
const { reservation } = await this.reservations.update(reservationId, tripId, {
// The edit the booking form's PUT makes, through the same use case: a stay
// written for the first time is announced as created, a changed one as updated.
const { reservation } = await this.writes.updateBooking(String(reservationId), String(tripId), {
place_id,
type: current.type,
status: current.status as string,
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
}, current);
this.guards.safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
this.guards.safeBroadcast(tripId, 'reservation:updated', { reservation });
}, current, undefined, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ reservation, accommodation_id: reservation?.accommodation_id ?? null });
}
@@ -736,7 +720,7 @@ export class ReservationsMcp {
if (price != null) meta.price = String(price);
const { reservation, costEvents } = await this.reservations.createWithCost(String(tripId), {
const { reservation } = await this.writes.createBooking(String(tripId), {
title,
type,
reservation_time: departureTime,
@@ -751,10 +735,7 @@ export class ReservationsMcp {
metadata: Object.keys(meta).length > 0 ? meta : undefined,
endpoints: transportEndpoints,
needs_review,
}, linkedCost(price, budget_category || type));
for (const { event, payload } of costEvents) this.guards.safeBroadcast(tripId, event, payload);
this.guards.safeBroadcast(tripId, 'reservation:created', { reservation });
}, linkedCost(price, budget_category || type), await this.guards.tripWriter(tripId, ctx.userId));
return ok({ reservation });
}
@@ -856,8 +837,9 @@ export class ReservationsMcp {
if (applied.endpointsChanged) resolvedEndpoints = applied.endpoints;
}
// One write with the linked expenses a type change re-files, as on update_reservation.
const { reservation, accommodationChanged, costEvents } = await this.reservations.updateWithCost(String(reservationId), String(tripId), {
// One write with the linked expenses a type change re-files, as on update_reservation;
// a linked AirTrail flight is pushed back, as on REST.
const { reservation } = await this.writes.updateBooking(String(reservationId), String(tripId), {
title,
type,
reservation_time: departureTime,
@@ -871,10 +853,7 @@ export class ReservationsMcp {
metadata: nextMetadata,
endpoints: resolvedEndpoints,
needs_review,
}, existing, undefined);
if (accommodationChanged) this.guards.safeBroadcast(tripId, 'accommodation:updated', {});
for (const { event, payload } of costEvents) this.guards.safeBroadcast(tripId, event, payload);
this.guards.safeBroadcast(tripId, 'reservation:updated', { reservation });
}, existing, undefined, await this.guards.tripWriter(tripId, ctx.userId));
return ok({ reservation });
}
@@ -891,10 +870,9 @@ export class ReservationsMcp {
async deleteTransport({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) {
if (!(await this.reservations.verifyTripAccess(tripId, ctx.userId))) return noAccess();
if (!(await this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId))) return permissionDenied();
const { deleted, deletedBudgetItemIds } = await this.reservations.remove(reservationId, tripId);
if (!deleted) return errorResult('Transport not found.');
for (const itemId of deletedBudgetItemIds) this.guards.safeBroadcast(tripId, 'budget:deleted', { itemId });
this.guards.safeBroadcast(tripId, 'reservation:deleted', { reservationId });
return ok({ success: true });
// The same cascade as delete_reservation: a row a REST edit retyped from a
// hotel still takes its stay along, and says so with accommodation:deleted.
const writer = await this.guards.tripWriter(tripId, ctx.userId);
return ok(await this.writes.deleteBooking(reservationId, tripId, writer, 'Transport not found.'));
}
}
@@ -20,6 +20,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { RealtimeModule } from '../realtime/realtime.module';
import { ReservationWritesService } from './reservation-writes.service';
import { ReservationsReadModule } from './reservations-read.module';
import { ReservationsController } from './reservations.controller';
import { ReservationsMcp } from './reservations.mcp';
@@ -79,7 +80,8 @@ import { Module } from '@nestjs/common';
]),
],
controllers: [ReservationsController, UpcomingReservationsController],
providers: [ReservationsService, ReservationsMcp, ReservationsRpc],
// ReservationWritesService: the booking use cases the controller and the MCP tools share.
providers: [ReservationsService, ReservationWritesService, ReservationsMcp, ReservationsRpc],
// For in-container consumers (ReservationsRpc, TripsService, BookingImportService).
exports: [ReservationsService],
})
@@ -68,14 +68,17 @@ export class ReservationsRpc {
if (!current) throw new ForbiddenResource(`no reservation ${reservationId} on trip ${tripId}`);
await this.requireOwnReferences(tripId, input);
const i = input as { title?: string; type?: string; create_budget_entry?: unknown };
const { reservation, accommodationChanged, costEvents } = await this.reservations.updateWithCost(
String(reservationId),
String(tripId),
input as never,
current,
i.create_budget_entry as never,
);
if (accommodationChanged) this.realtime.broadcast(tripId, 'accommodation:updated', {}, undefined);
const { reservation, accommodationChanged, accommodationCreated, costEvents } =
await this.reservations.updateWithCost(
String(reservationId),
String(tripId),
input as never,
current,
i.create_budget_entry as never,
);
// A stay this edit wrote for the first time is new, as the REST route announces it.
if (accommodationCreated) this.realtime.broadcast(tripId, 'accommodation:created', {}, undefined);
else if (accommodationChanged) this.realtime.broadcast(tripId, 'accommodation:updated', {}, undefined);
const cur = current as { title: string; type?: string };
this.reservations.announceCost(tripId, costEvents, undefined);
this.realtime.broadcast(tripId, 'reservation:updated', { reservation }, undefined);
@@ -43,7 +43,7 @@ import { decodeJson } from '../../utils/json-column';
type Trip = TripAccess;
type BudgetEntry = { total_price?: number; category?: string; currency?: string | null; exchange_rate?: number } | undefined;
/** A booking's cost as the service writes it: the entry, dated on the booking's day (bookingDate), never by the caller. */
/** A booking's cost as written: the entry, dated on the booking's day (bookingDate), never by the caller. */
type DatedBudgetEntry = (NonNullable<BudgetEntry> & { expense_date?: string | null }) | undefined;
/** What a booking write did to its linked costs, for the caller to send once the write has committed. */
@@ -842,7 +842,7 @@ export class ReservationsService {
return written;
}
private async updateInTx(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): Promise<{ reservation: ReservationRow; accommodationChanged: boolean; stayMirror: AccommodationMirror }> {
private async updateInTx(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation) {
const {
title, reservation_time, reservation_end_time, location,
confirmation_number, notes, url, day_id, end_day_id, place_id, assignment_id,
@@ -891,6 +891,7 @@ export class ReservationsService {
const accExists = await this.dayAccommodationsRepo.existsInTrip(accIdForRead(resolvedAccId)!, this.rowIdNum(tripId));
if (!accExists) resolvedAccId = null;
}
const hadStay = Boolean(resolvedAccId);
if (type === 'hotel' && create_accommodation) {
const { place_id: accPlaceId, start_day_id, end_day_id, check_in, check_out, confirmation: accConf } = create_accommodation;
if (start_day_id && end_day_id) {
@@ -1027,7 +1028,7 @@ export class ReservationsService {
// The caller passed the pre-checked `current` row, so the re-select can't
// miss (legacy typed this any).
const reservation = (await this.getReservationWithJoins(id))!;
return { reservation, accommodationChanged, stayMirror };
return { reservation, accommodationChanged, accommodationCreated: !hadStay && Boolean(resolvedAccId), stayMirror };
}
/** The accommodation + budget-item + reservation deletes are one logical
@@ -1,4 +1,133 @@
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import {
AirtrailClient,
type AirtrailAirport,
type AirtrailFlightRaw,
} from '../../../src/nest/integrations/airtrail.client';
import { AirtrailService } from '../../../src/nest/integrations/airtrail.service';
import { makeBudgetItem } from '../../helpers/factories/budget';
import { updateRows } from '../../helpers/factories/rows';
import type { CaseGroup } from './types';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import { vi } from 'vitest';
/**
* The notice a booking write sends the other travellers of the trip
* (`notifyBookingChange`), which reaches their bell a beat after the write
* answered. MCP sends it since REST and the tools share the booking use cases.
*/
const BOOKING_NOTICE = { event: 'notification:new', user: 'others' } as const;
/** A hotel booking's stay, written by the booking and announced to every socket before the booking's own events. */
const STAY_STOP_ADDED = [
{ event: 'assignment:created', all: true },
{ event: 'place:updated', all: true },
{ event: 'assignment:reordered', all: true },
] as const;
/** The stop of a stay that goes, and the order of its day. */
const STAY_STOP_REMOVED = [
{ event: 'assignment:deleted', all: true },
{ event: 'assignment:reordered', all: true },
] as const;
/** A walk and one subway ride in Osaka, the morning of the scene's first day (09:00 to 09:30 local). */
const TRANSIT_FROM = { name: 'Namba', lat: 34.667, lng: 135.501 };
const TRANSIT_TO = { name: 'Umeda', lat: 34.702, lng: 135.496 };
const TRANSIT_ITINERARY = {
startTime: '2027-05-03T00:00:00Z',
endTime: '2027-05-03T00:30:00Z',
duration: 1800,
transfers: 0,
walkSeconds: 300,
legs: [
{
mode: 'WALK',
from: { ...TRANSIT_FROM, name: 'START', time: '2027-05-03T00:00:00Z', scheduledTime: null, track: null },
to: {
name: 'Namba Station',
lat: 34.666,
lng: 135.5,
time: '2027-05-03T00:05:00Z',
scheduledTime: null,
track: null,
},
duration: 300,
distance: 300,
headsign: null,
line: null,
lineColor: null,
lineTextColor: null,
agency: null,
intermediateStops: 0,
geometry: null,
geometryPrecision: 6,
},
{
mode: 'SUBWAY',
from: {
name: 'Namba Station',
lat: 34.666,
lng: 135.5,
time: '2027-05-03T00:05:00Z',
scheduledTime: null,
track: '1',
},
to: { ...TRANSIT_TO, name: 'END', time: '2027-05-03T00:30:00Z', scheduledTime: null, track: '2' },
duration: 1500,
distance: 5000,
headsign: 'Umeda',
line: 'M',
lineColor: '#E5171F',
lineTextColor: '#FFFFFF',
agency: 'Osaka Metro',
intermediateStops: 3,
geometry: 'encoded',
geometryPrecision: 6,
},
],
};
function airport(id: number, iata: string, name: string, lat: number, lon: number, tz: string): AirtrailAirport {
return { id, icao: null, iata, name, lat, lon, tz, country: null };
}
/** The flight the stubbed AirTrail account holds, on the scene's first day. */
const AIRTRAIL_FLIGHT: AirtrailFlightRaw = {
id: 501,
from: airport(1, 'BRU', 'Brussels', 50.9014, 4.4844, 'Europe/Brussels'),
to: airport(2, 'HEL', 'Helsinki-Vantaa', 60.3172, 24.9633, 'Europe/Helsinki'),
date: '2027-05-03',
datePrecision: 'day',
departure: '2027-05-03T06:00:00.000+00:00',
arrival: '2027-05-03T09:30:00.000+00:00',
departureScheduled: null,
arrivalScheduled: null,
airline: { id: 1, icao: 'FIN', iata: 'AY', name: 'Finnair' },
flightNumber: 'AY1502',
aircraft: null,
aircraftReg: null,
note: null,
seats: [],
};
/** A connected AirTrail account holding AIRTRAIL_FLIGHT, without the network. */
function stubAirtrail(app: INestApplication): () => void {
const spies = [
vi
.spyOn(app.get(AirtrailService), 'getAirtrailCredentials')
.mockResolvedValue({ baseUrl: 'https://airtrail.example.test', apiKey: 'key', allowInsecureTls: false }),
vi.spyOn(app.get(AirtrailClient), 'listFlights').mockResolvedValue([AIRTRAIL_FLIGHT]),
];
return () => spies.forEach((spy) => spy.mockRestore());
}
/** Sets a booking's type, as an edit of the booking form does. */
function retype(app: INestApplication, id: number, type: string): Promise<number> {
return updateRows(app.get(MikroORM), Reservations, { id }, { type });
}
/** Bookings: reservations, transports, transit, stays and the AirTrail import. */
export const bookings: CaseGroup = {
@@ -22,29 +151,213 @@ export const bookings: CaseGroup = {
// The stay, the ping for its partner hotel booking, then the check-in stop
// the day plan gains (with the stop's time stamped on the hotel place),
// which the booking tab has not drawn yet either.
expect: [
'accommodation:created',
'reservation:created',
{ event: 'assignment:created', all: true },
{ event: 'place:updated', all: true },
{ event: 'assignment:reordered', all: true },
],
expect: ['accommodation:created', 'reservation:created', ...STAY_STOP_ADDED],
},
{
tool: 'update_accommodation',
needs: ['stay'],
args: (s) => ({ tripId: s.trip.id, accommodationId: s.stay.id, notes: 'Late arrival' }),
rest: {
method: 'put',
path: (s) => `/api/trips/${s.trip.id}/accommodations/${s.stay.id}`,
body: () => ({ notes: 'Late arrival' }),
},
expect: ['accommodation:updated'],
},
{
tool: 'delete_accommodation',
needs: ['stay'],
args: (s) => ({ tripId: s.trip.id, accommodationId: s.stay.id }),
rest: { method: 'delete', path: (s) => `/api/trips/${s.trip.id}/accommodations/${s.stay.id}` },
// The stop goes first, then the partner booking, then the stay (MCP names
// it `id` too and lists the bookings it took along).
expect: [...STAY_STOP_REMOVED, 'reservation:deleted', 'accommodation:deleted'],
},
{
tool: 'import_airtrail_flights',
args: (s) => ({ tripId: s.trip.id, flightIds: [String(AIRTRAIL_FLIGHT.id)] }),
rest: {
method: 'post',
path: (s) => `/api/trips/${s.trip.id}/reservations/import/airtrail`,
body: () => ({ flightIds: [String(AIRTRAIL_FLIGHT.id)] }),
},
stub: stubAirtrail,
expect: ['reservation:created'],
},
{
// A hotel with its stay and a price: the stop, then the stay ping, the cost and the booking.
tool: 'create_reservation',
needs: ['place', 'member'],
args: (s) => ({
tripId: s.trip.id,
title: `Hotel ${s.n}`,
type: 'hotel',
place_id: s.place.id,
start_day_id: s.days[0].id,
end_day_id: s.days[1].id,
price: 240,
}),
rest: {
method: 'post',
path: (s) => `/api/trips/${s.trip.id}/reservations`,
body: (s) => ({
title: `Hotel ${s.n}`,
type: 'hotel',
place_id: s.place.id,
create_accommodation: { place_id: s.place.id, start_day_id: s.days[0].id, end_day_id: s.days[1].id },
create_budget_entry: { total_price: 240, category: 'hotel' },
}),
},
settle: true,
expect: [...STAY_STOP_ADDED, 'accommodation:created', 'budget:created', 'reservation:created', BOOKING_NOTICE],
},
{
tool: 'update_reservation',
needs: ['stay', 'member'],
args: (s) => ({ tripId: s.trip.id, reservationId: s.stay.reservationId, status: 'confirmed' }),
rest: {
method: 'put',
path: (s) => `/api/trips/${s.trip.id}/reservations/${s.stay.reservationId}`,
body: () => ({ status: 'confirmed' }),
},
settle: true,
expect: ['reservation:updated', BOOKING_NOTICE],
},
{
// A hotel booking takes its stay (and the stay's stop) and its linked cost along.
tool: 'delete_reservation',
needs: ['stay', 'member'],
setup: async (s, app) => {
await makeBudgetItem(app.get(MikroORM), s.trip.id, { name: `Hotel ${s.n}`, reservation: s.stay.reservationId });
},
args: (s) => ({ tripId: s.trip.id, reservationId: s.stay.reservationId }),
rest: { method: 'delete', path: (s) => `/api/trips/${s.trip.id}/reservations/${s.stay.reservationId}` },
settle: true,
expect: [...STAY_STOP_REMOVED, 'accommodation:deleted', 'budget:deleted', 'reservation:deleted', BOOKING_NOTICE],
},
{
tool: 'set_reservation_travelers',
needs: ['transport', 'member'],
args: (s) => ({ tripId: s.trip.id, reservationId: s.transport.id, user_ids: [s.member.id] }),
rest: {
method: 'put',
path: (s) => `/api/trips/${s.trip.id}/reservations/${s.transport.id}/travelers`,
body: (s) => ({ user_ids: [s.member.id] }),
},
expect: ['reservation:travelers-updated'],
},
{
// MCP sends REST's `day_id` and keeps the `dayId` its tool always sent.
tool: 'reorder_reservations',
needs: ['transport'],
args: (s) => ({
tripId: s.trip.id,
positions: [{ id: s.transport.id, day_plan_position: 2 }],
dayId: s.days[0].id,
}),
rest: {
method: 'put',
path: (s) => `/api/trips/${s.trip.id}/reservations/positions`,
body: (s) => ({ positions: [{ id: s.transport.id, day_plan_position: 2 }], day_id: s.days[0].id }),
},
expect: ['reservation:positions'],
},
{
// A hotel booking without a stay yet gets one, as the booking form's PUT
// gives it: the stop, then the new stay, then the booking.
tool: 'link_hotel_accommodation',
needs: ['transport', 'place', 'member'],
setup: async (s, app) => {
await retype(app, s.transport.id, 'hotel');
},
args: (s) => ({
tripId: s.trip.id,
reservationId: s.transport.id,
place_id: s.place.id,
start_day_id: s.days[0].id,
end_day_id: s.days[1].id,
}),
rest: {
method: 'put',
path: (s) => `/api/trips/${s.trip.id}/reservations/${s.transport.id}`,
body: (s) => ({
type: 'hotel',
place_id: s.place.id,
create_accommodation: { place_id: s.place.id, start_day_id: s.days[0].id, end_day_id: s.days[1].id },
}),
},
settle: true,
expect: [...STAY_STOP_ADDED, 'accommodation:created', 'reservation:updated', BOOKING_NOTICE],
},
{
tool: 'create_transport',
needs: ['member'],
args: (s) => ({ tripId: s.trip.id, type: 'train', title: `ICE ${s.n}`, start_day_id: s.days[0].id, price: 89 }),
rest: {
method: 'post',
path: (s) => `/api/trips/${s.trip.id}/reservations`,
body: (s) => ({
title: `ICE ${s.n}`,
type: 'train',
day_id: s.days[0].id,
create_budget_entry: { total_price: 89, category: 'train' },
}),
},
settle: true,
expect: ['budget:created', 'reservation:created', BOOKING_NOTICE],
},
{
tool: 'update_transport',
needs: ['transport', 'member'],
args: (s) => ({ tripId: s.trip.id, reservationId: s.transport.id, status: 'confirmed' }),
rest: {
method: 'put',
path: (s) => `/api/trips/${s.trip.id}/reservations/${s.transport.id}`,
body: () => ({ status: 'confirmed' }),
},
settle: true,
expect: ['reservation:updated', BOOKING_NOTICE],
},
{
// A hotel booking a REST edit retyped as a train still carries its stay:
// deleting it takes the stay along, and the sidebar hears of it on MCP too.
tool: 'delete_transport',
needs: ['stay', 'member'],
setup: async (s, app) => {
await retype(app, s.stay.reservationId, 'train');
},
args: (s) => ({ tripId: s.trip.id, reservationId: s.stay.reservationId }),
rest: { method: 'delete', path: (s) => `/api/trips/${s.trip.id}/reservations/${s.stay.reservationId}` },
settle: true,
expect: [...STAY_STOP_REMOVED, 'accommodation:deleted', 'reservation:deleted', BOOKING_NOTICE],
},
{
tool: 'create_transit_journey',
needs: ['member'],
args: (s) => ({
tripId: s.trip.id,
dayId: s.days[0].id,
from: TRANSIT_FROM,
to: TRANSIT_TO,
itinerary: TRANSIT_ITINERARY,
}),
// The transit panel saves the itinerary it picked through the booking route.
rest: {
method: 'post',
path: (s) => `/api/trips/${s.trip.id}/reservations`,
body: (s) => ({
title: 'Namba → Umeda',
type: 'transit',
status: 'confirmed',
day_id: s.days[0].id,
end_day_id: s.days[0].id,
reservation_time: '2027-05-03T09:00',
reservation_end_time: '2027-05-03T09:30',
}),
},
settle: true,
expect: ['reservation:created', BOOKING_NOTICE],
},
],
exempt: [],
pending: [
'update_accommodation',
'delete_accommodation',
'import_airtrail_flights',
'create_reservation',
'update_reservation',
'delete_reservation',
'set_reservation_travelers',
'reorder_reservations',
'link_hotel_accommodation',
'create_transport',
'update_transport',
'delete_transport',
'create_transit_journey',
],
};
@@ -238,6 +238,23 @@ describe('Tool: reorder_reservations', () => {
});
});
it("names the day under REST's day_id and keeps the dayId the tool always sent", async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
const res = createReservation(testDb, trip.id, { title: 'Train', type: 'train', day_id: day.id });
const positions = [{ id: res.id, day_plan_position: 3 }];
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'reorder_reservations',
arguments: { tripId: trip.id, positions, dayId: day.id },
});
});
expect(broadcastMock.mock.calls.filter((c) => c[1] === 'reservation:positions')).toEqual([
[trip.id, 'reservation:positions', { positions, day_id: day.id, dayId: day.id, _source: 'mcp' }],
]);
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
@@ -9,6 +9,7 @@ import { ReservationDayPositions } from '../../../src/db/entities/ReservationDay
import { ReservationTravelers } from '../../../src/db/entities/ReservationTravelers.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { Trips } from '../../../src/db/entities/Trips.entity';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import {
createUser,
createTrip,
@@ -478,6 +479,51 @@ describe('Tool: delete_reservation', () => {
});
});
// ---------------------------------------------------------------------------
// The notice to fellow travellers
// ---------------------------------------------------------------------------
describe('Reservation tools: the booking notice', () => {
// The booking routes always told the other travellers; the tools did not.
it('create, update, the hotel link and delete each tell the fellow travellers, as REST does', async () => {
const notify = vi.spyOn(ReservationsService.prototype, 'notifyBookingChange').mockResolvedValue(undefined);
try {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id, { day_number: 1 });
const day2 = createDay(testDb, trip.id, { day_number: 2 });
const hotel = createPlace(testDb, trip.id, { name: 'Ritz' });
await withHarness(user.id, async (h) => {
const created = parseToolResult(
await h.client.callTool({
name: 'create_reservation',
arguments: { tripId: trip.id, title: 'Ritz', type: 'hotel' },
}),
) as { reservation: { id: number } };
const reservationId = created.reservation.id;
await h.client.callTool({
name: 'update_reservation',
arguments: { tripId: trip.id, reservationId, title: 'Ritz Paris' },
});
await h.client.callTool({
name: 'link_hotel_accommodation',
arguments: { tripId: trip.id, reservationId, place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id },
});
await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId } });
});
// The trip id travels as text from some callers and as a number from others; the notice reads both alike.
expect(notify.mock.calls.map(([tripId, ...rest]) => [Number(tripId), ...rest])).toEqual([
[trip.id, user.id, 'Ritz', 'hotel'],
[trip.id, user.id, 'Ritz Paris', 'hotel'],
[trip.id, user.id, 'Ritz Paris', 'hotel'],
[trip.id, user.id, 'Ritz Paris', 'hotel'],
]);
} finally {
notify.mockRestore();
}
});
});
// ---------------------------------------------------------------------------
// link_hotel_accommodation
// ---------------------------------------------------------------------------
@@ -512,6 +558,8 @@ describe('Tool: link_hotel_accommodation', () => {
expect(Number.isInteger(data.reservation.accommodation_id)).toBe(true);
expect(Number.isInteger(data.accommodation_id)).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
// A new stay is created, not also updated: the booking form's PUT sends the same.
expect(broadcastMock.mock.calls.some((c) => c[1] === 'accommodation:updated')).toBe(false);
});
});
@@ -540,6 +588,7 @@ describe('Tool: link_hotel_accommodation', () => {
});
// Update link
broadcastMock.mockClear();
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'link_hotel_accommodation',
@@ -552,6 +601,8 @@ describe('Tool: link_hotel_accommodation', () => {
},
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object));
// The stay already existed, so it is not announced as a new one.
expect(broadcastMock.mock.calls.some((c) => c[1] === 'accommodation:created')).toBe(false);
});
});
+115 -1
View File
@@ -6,9 +6,12 @@
*/
import { db as testDb } from '../../../src/db/database';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity';
import { Reservations } from '../../../src/db/entities/Reservations.entity';
import { createUser, createTrip, createDay } from '../../helpers/factories';
import { AirtrailLinkService } from '../../../src/nest/integrations/airtrail-link.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { createUser, createTrip, createDay, createPlace } from '../../helpers/factories';
import { makeBudgetItem } from '../../helpers/factories/budget';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { makeDay } from '../../helpers/factories/trips';
@@ -524,6 +527,48 @@ describe('Tool: delete_transport', () => {
});
});
// A hotel booking the booking form retyped as a train keeps its stay link. The
// tool used to take the stay along without saying so, so the planner's hotel
// block outlived it until a reload; REST always sent accommodation:deleted.
it('takes along the stay a booking retyped from a hotel still carries, and says so as REST does', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id, { day_number: 1 });
const day2 = createDay(testDb, trip.id, { day_number: 2 });
const hotel = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
await withHarness(user.id, async (h) => {
const created = await h.client.callTool({
name: 'create_reservation',
arguments: {
tripId: trip.id,
title: 'Grand Hotel',
type: 'hotel',
place_id: hotel.id,
start_day_id: day1.id,
end_day_id: day2.id,
},
});
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
await updateRows(orm, Reservations, { id: reservation.id }, { type: 'train' });
broadcastMock.mockClear();
const result = await h.client.callTool({
name: 'delete_transport',
arguments: { tripId: trip.id, reservationId: reservation.id },
});
expect(parseToolResult(result)).toEqual({ success: true });
expect(await countRows(orm, DayAccommodations, { trip: trip.id })).toBe(0);
expect(broadcastMock.mock.calls.map((c) => c[1]).slice(-2)).toEqual([
'accommodation:deleted',
'reservation:deleted',
]);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', {
accommodationId: expect.anything(),
_source: 'mcp',
});
});
});
it('404s an unknown reservation id', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -552,6 +597,75 @@ describe('Tool: delete_transport', () => {
});
});
// ---------------------------------------------------------------------------
// What a transport write does past the trip's room, as on REST
// ---------------------------------------------------------------------------
describe('Transport tools: the booking notice and the AirTrail push', () => {
it('create, update and delete each tell the fellow travellers, as the booking routes do', async () => {
const notify = vi.spyOn(ReservationsService.prototype, 'notifyBookingChange').mockResolvedValue(undefined);
try {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const created = parseToolResult(
await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'train', title: 'ICE 1001' },
}),
) as { reservation: { id: number } };
const reservationId = created.reservation.id;
await h.client.callTool({
name: 'update_transport',
arguments: { tripId: trip.id, reservationId, title: 'ICE 1003' },
});
await h.client.callTool({ name: 'delete_transport', arguments: { tripId: trip.id, reservationId } });
});
// The trip id travels as text from some callers and as a number from others; the notice reads both alike.
expect(notify.mock.calls.map(([tripId, ...rest]) => [Number(tripId), ...rest])).toEqual([
[trip.id, user.id, 'ICE 1001', 'train'],
[trip.id, user.id, 'ICE 1003', 'train'],
[trip.id, user.id, 'ICE 1003', 'train'],
]);
} finally {
notify.mockRestore();
}
});
// Before, an edit over MCP stayed local and the next AirTrail pull overwrote it.
it('pushes an edit of a linked AirTrail flight back to AirTrail, and only of a linked one', async () => {
const push = vi.spyOn(AirtrailLinkService.prototype, 'pushReservationToAirtrail').mockResolvedValue(undefined);
try {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
let linked = 0;
await withHarness(user.id, async (h) => {
const create = async (title: string) =>
(
parseToolResult(
await h.client.callTool({
name: 'create_transport',
arguments: { tripId: trip.id, type: 'flight', title, endpoints: flightEndpoints },
}),
) as { reservation: { id: number } }
).reservation.id;
linked = await create('AY1502');
const local = await create('LX638');
await updateRows(orm, Reservations, { id: linked }, { external_source: 'airtrail', external_id: '501' });
for (const reservationId of [linked, local]) {
await h.client.callTool({
name: 'update_transport',
arguments: { tripId: trip.id, reservationId, status: 'confirmed' },
});
}
});
expect(push.mock.calls).toEqual([[linked, trip.id]]);
} finally {
push.mockRestore();
}
});
});
// ---------------------------------------------------------------------------
// Multi-leg bookings (#1914)
//
@@ -1,5 +1,6 @@
import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller';
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
import { DomainError } from '../../../src/nest/common/domain-error';
import type { User } from '../../../src/types';
import { HttpException } from '@nestjs/common';
@@ -268,3 +269,101 @@ describe('AccommodationsController (parity with the legacy accommodations sub-ro
});
});
});
/**
* The gate and the checks every surface shares, called on the use cases
* directly with an MCP writer. That REST and MCP send the same events is held
* by the broadcast parity group (tests/integration/broadcast-parity/bookings.cases.ts).
*/
describe('stay use cases: the shared gate and checks', () => {
const writer = (role = 'user') => ({
userId: 1,
role,
surface: 'mcp' as const,
events: { emit: vi.fn(), emitAll: vi.fn() },
});
const stay = { place_id: 2, start_day_id: 10, end_day_id: 11 };
const cancelled = {
linkedReservationId: 4,
deletedBudgetItemId: null,
linkedReservationIds: [4, 5],
deletedBudgetItemIds: [],
mirror: noMirror,
};
it("ACC-UC-001: a trip the writer cannot see refuses with REST's 404 and MCP's own wording", async () => {
const svc = makeService({ trips: { findAccessible: vi.fn().mockResolvedValue(undefined) } });
const err = await svc.createStay(5, stay, writer()).catch((e: unknown) => e);
expect(err).toBeInstanceOf(DomainError);
expect((err as DomainError).getStatus()).toBe(404);
expect((err as DomainError).toBody()).toEqual({ error: 'Trip not found' });
expect((err as DomainError).mcpMessage).toBe('Trip not found or access denied.');
});
it('ACC-UC-002: a writer without day_edit is refused before anything is read or written', async () => {
const create = vi.fn();
const svc = makeService({ permissions: { checkPermission: vi.fn().mockResolvedValue(false) }, create });
const err = (await svc.createStay(5, stay, writer()).catch((e: unknown) => e)) as DomainError;
expect(err.getStatus()).toBe(403);
expect(err.mcpMessage).toBe('You do not have permission to perform this action on this trip.');
expect(create).not.toHaveBeenCalled();
});
it("ACC-UC-003: the permission check sees the writer's role and whether it is a member", async () => {
const checkPermission = vi.fn().mockResolvedValue(true);
const svc = makeService({
permissions: { checkPermission },
trips: { findAccessible: vi.fn().mockResolvedValue({ user_id: 2 }) },
get: vi.fn().mockResolvedValue({ id: 9 }),
remove: vi.fn().mockResolvedValue(cancelled),
});
await svc.deleteStay(5, 9, writer('admin'));
expect(checkPermission).toHaveBeenCalledWith('day_edit', 'admin', 2, 1, true);
});
it('ACC-UC-004: every reference miss is listed for MCP, the first one answers REST', async () => {
const validateRefs = vi.fn().mockResolvedValue([{ message: 'Place not found' }, { message: 'End day not found' }]);
const svc = makeService({ validateRefs });
const err = (await svc.createStay(5, stay, writer()).catch((e: unknown) => e)) as DomainError;
expect(err.getStatus()).toBe(404);
expect(err.toBody()).toEqual({ error: 'Place not found' });
expect(err.mcpMessage).toBe('Place not found, End day not found');
});
it('ACC-UC-005: an edit checks the references it names, on MCP as on REST', async () => {
// The MCP tool used to write a place or day it never looked up.
const update = vi.fn();
const validateRefs = vi.fn().mockResolvedValue([{ message: 'Place not found' }]);
const svc = makeService({ validateRefs, update, get: vi.fn().mockResolvedValue({ id: 9 }) });
const err = (await svc.updateStay(5, 9, { place_id: 99 }, writer()).catch((e: unknown) => e)) as DomainError;
expect(err.getStatus()).toBe(404);
expect(validateRefs).toHaveBeenCalledWith(5, 99, undefined, undefined);
expect(update).not.toHaveBeenCalled();
});
it('ACC-UC-006: an edit or a cancellation of a stay that is not on the trip is a 404', async () => {
const svc = makeService({ get: vi.fn().mockResolvedValue(undefined) });
const errors = (await Promise.all(
[svc.updateStay(5, 9, {}, writer()), svc.deleteStay(5, 9, writer())].map((p) => p.catch((e: unknown) => e)),
)) as DomainError[];
for (const err of errors) {
expect(err.toBody()).toEqual({ error: 'Accommodation not found' });
expect(err.mcpMessage).toBe('Accommodation not found.');
}
});
it("ACC-UC-007: a cancellation over MCP keeps the keys its tool always sent next to REST's", async () => {
const svc = makeService({
get: vi.fn().mockResolvedValue({ id: 9 }),
remove: vi.fn().mockResolvedValue(cancelled),
});
const mcp = writer();
await svc.deleteStay(5, 9, mcp);
expect(mcp.events.emit).toHaveBeenLastCalledWith('accommodation:deleted', {
accommodationId: 9,
id: 9,
linkedReservationId: 4,
linkedReservationIds: [4, 5],
});
});
});
@@ -1,193 +0,0 @@
import { AccommodationsController } from '../../../src/nest/accommodations/accommodations.controller';
import { AccommodationsMcp } from '../../../src/nest/accommodations/accommodations.mcp';
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
import { DomainError } from '../../../src/nest/common/domain-error';
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
import type { User } from '../../../src/types';
import { describe, it, expect, vi } from 'vitest';
/**
* REST and MCP are adapters over the same stay use cases, so a client sees the
* same events whichever of the two made the change. Before, the MCP tools sent
* no reservation:created on a booking and no reservation:deleted/budget:deleted
* on its cascade. Each case runs one write through both surfaces against the
* same stubbed persistence and compares the event names in order.
*/
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
const stop = { id: 77, day_id: 10 };
const mirror = { created: stop, moved: null, updated: [], removed: [{ id: 70, dayId: 10 }], stamped: null };
function makeService(realtime: { broadcast: ReturnType<typeof vi.fn> }, overrides: Record<string, unknown> = {}) {
const stubs: Record<string, unknown> = {
trips: { findAccessible: vi.fn().mockResolvedValue({ user_id: 1 }) },
permissions: { checkPermission: vi.fn().mockResolvedValue(true) },
realtime,
assignments: { reconcile: vi.fn() },
dayAssignmentsRepo: { listIdsForDay: vi.fn().mockResolvedValue([70, 77]) },
validateAccommodationRefs: vi.fn().mockResolvedValue([]),
getAccommodation: vi.fn().mockResolvedValue({ id: 9 }),
createAccommodation: vi.fn().mockResolvedValue({ accommodation: { id: 9 }, mirror }),
updateAccommodation: vi.fn().mockResolvedValue({ accommodation: { id: 9 }, mirror }),
deleteAccommodation: vi.fn().mockResolvedValue({
linkedReservationId: 4,
deletedBudgetItemId: 7,
linkedReservationIds: [4, 5],
deletedBudgetItemIds: [7],
mirror,
}),
...overrides,
};
return Object.assign(
Object.create(AccommodationsService.prototype) as object,
stubs,
) as unknown as AccommodationsService;
}
function surfaces(overrides: Record<string, unknown> = {}) {
const restRealtime = { broadcast: vi.fn() };
const mcpRealtime = { broadcast: vi.fn() };
const rest = new AccommodationsController(makeService(restRealtime, overrides));
const mcpService = makeService(mcpRealtime, overrides);
const guards = new McpToolGuardsService(
{ getOwnerId: vi.fn().mockResolvedValue(1) } as never,
{ getRole: vi.fn().mockResolvedValue('user') } as never,
{ checkPermission: vi.fn().mockResolvedValue(true) } as never,
mcpRealtime as never,
);
const mcp = new AccommodationsMcp(mcpService, {} as never, guards, {} as never);
const names = (rt: { broadcast: ReturnType<typeof vi.fn> }) => rt.broadcast.mock.calls.map((c) => c[1] as string);
const payloadOf = (rt: { broadcast: ReturnType<typeof vi.fn> }, event: string) =>
rt.broadcast.mock.calls.find((c) => c[1] === event)?.[2] as Record<string, unknown> | undefined;
return { rest, mcp, restRealtime, mcpRealtime, names, payloadOf, ctx: { userId: 1, scopes: null } as never };
}
describe('accommodation events: REST == MCP', () => {
it('ACC-PARITY-001: a booking sends the same events on both surfaces, reservation:created included', async () => {
const s = surfaces();
await s.rest.create(user, '5', { place_id: 2, start_day_id: 10, end_day_id: 11 }, 'sock');
await s.mcp.createAccommodation({ tripId: 5, place_id: 2, start_day_id: 10, end_day_id: 11 }, s.ctx);
expect(s.names(s.mcpRealtime)).toEqual(s.names(s.restRealtime));
expect(s.names(s.restRealtime)).toEqual([
'accommodation:created',
'reservation:created',
'assignment:deleted',
'assignment:created',
'assignment:reordered',
]);
expect(s.payloadOf(s.mcpRealtime, 'reservation:created')).toEqual({ _source: 'mcp' });
});
it('ACC-PARITY-002: an edit sends the same events on both surfaces', async () => {
const s = surfaces();
await s.rest.update(user, '5', '9', { notes: 'x' }, 'sock');
await s.mcp.updateAccommodation({ tripId: 5, accommodationId: 9, notes: 'x' }, s.ctx);
expect(s.names(s.mcpRealtime)).toEqual(s.names(s.restRealtime));
expect(s.names(s.restRealtime)[0]).toBe('accommodation:updated');
});
it('ACC-PARITY-003: a cancellation sends the reservation and budget cascade on both surfaces', async () => {
const s = surfaces();
await s.rest.remove(user, '5', '9', 'sock');
await s.mcp.deleteAccommodation({ tripId: 5, accommodationId: 9 }, s.ctx);
expect(s.names(s.mcpRealtime)).toEqual(s.names(s.restRealtime));
expect(s.names(s.restRealtime).slice(-4)).toEqual([
'reservation:deleted',
'reservation:deleted',
'budget:deleted',
'accommodation:deleted',
]);
// REST keeps its canonical payload; MCP adds it next to the keys its tool always sent.
expect(s.payloadOf(s.restRealtime, 'accommodation:deleted')).toEqual({ accommodationId: 9 });
expect(s.payloadOf(s.mcpRealtime, 'accommodation:deleted')).toEqual({
accommodationId: 9,
id: 9,
linkedReservationId: 4,
linkedReservationIds: [4, 5],
_source: 'mcp',
});
});
});
describe('stay use cases: the shared gate and checks', () => {
const writer = (role = 'user') => ({
userId: 1,
role,
surface: 'mcp' as const,
events: { emit: vi.fn(), emitAll: vi.fn() },
});
it("ACC-UC-001: a trip the writer cannot see refuses with REST's 404 and MCP's own wording", async () => {
const svc = makeService(
{ broadcast: vi.fn() },
{ trips: { findAccessible: vi.fn().mockResolvedValue(undefined) } },
);
const err = await svc
.createStay(5, { place_id: 2, start_day_id: 10, end_day_id: 11 }, writer())
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(DomainError);
expect((err as DomainError).getStatus()).toBe(404);
expect((err as DomainError).toBody()).toEqual({ error: 'Trip not found' });
expect((err as DomainError).mcpMessage).toBe('Trip not found or access denied.');
});
it('ACC-UC-002: a writer without day_edit is refused before anything is read or written', async () => {
const createAccommodation = vi.fn();
const svc = makeService(
{ broadcast: vi.fn() },
{ permissions: { checkPermission: vi.fn().mockResolvedValue(false) }, createAccommodation },
);
const err = (await svc
.createStay(5, { place_id: 2, start_day_id: 10, end_day_id: 11 }, writer())
.catch((e: unknown) => e)) as DomainError;
expect(err.getStatus()).toBe(403);
expect(err.mcpMessage).toBe('You do not have permission to perform this action on this trip.');
expect(createAccommodation).not.toHaveBeenCalled();
});
it("ACC-UC-003: the permission check sees the writer's role and whether it is a member", async () => {
const checkPermission = vi.fn().mockResolvedValue(true);
const svc = makeService(
{ broadcast: vi.fn() },
{ permissions: { checkPermission }, trips: { findAccessible: vi.fn().mockResolvedValue({ user_id: 2 }) } },
);
await svc.deleteStay(5, 9, writer('admin'));
expect(checkPermission).toHaveBeenCalledWith('day_edit', 'admin', 2, 1, true);
});
it('ACC-UC-004: every reference miss is listed for MCP, the first one answers REST', async () => {
const validateAccommodationRefs = vi
.fn()
.mockResolvedValue([{ message: 'Place not found' }, { message: 'End day not found' }]);
const svc = makeService({ broadcast: vi.fn() }, { validateAccommodationRefs });
const err = (await svc
.createStay(5, { place_id: 2, start_day_id: 10, end_day_id: 11 }, writer())
.catch((e: unknown) => e)) as DomainError;
expect(err.getStatus()).toBe(404);
expect(err.toBody()).toEqual({ error: 'Place not found' });
expect(err.mcpMessage).toBe('Place not found, End day not found');
});
it('ACC-UC-005: an edit checks the references it names, on MCP as on REST', async () => {
// The MCP tool used to write a place or day it never looked up.
const updateAccommodation = vi.fn();
const validateAccommodationRefs = vi.fn().mockResolvedValue([{ message: 'Place not found' }]);
const svc = makeService({ broadcast: vi.fn() }, { validateAccommodationRefs, updateAccommodation });
const err = (await svc.updateStay(5, 9, { place_id: 99 }, writer()).catch((e: unknown) => e)) as DomainError;
expect(err.getStatus()).toBe(404);
expect(validateAccommodationRefs).toHaveBeenCalledWith(5, 99, undefined, undefined);
expect(updateAccommodation).not.toHaveBeenCalled();
});
it('ACC-UC-006: an edit or a cancellation of a stay that is not on the trip is a 404', async () => {
const svc = makeService({ broadcast: vi.fn() }, { getAccommodation: vi.fn().mockResolvedValue(undefined) });
const errors = (await Promise.all(
[svc.updateStay(5, 9, {}, writer()), svc.deleteStay(5, 9, writer())].map((p) => p.catch((e: unknown) => e)),
)) as DomainError[];
for (const err of errors) {
expect(err.toBody()).toEqual({ error: 'Accommodation not found' });
expect(err.mcpMessage).toBe('Accommodation not found.');
}
});
});
@@ -1,4 +1,5 @@
import type { AirtrailLinkService } from '../../../src/nest/integrations/airtrail-link.service';
import { ReservationWritesService } from '../../../src/nest/reservations/reservation-writes.service';
import { ReservationsController } from '../../../src/nest/reservations/reservations.controller';
import type { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import type { User } from '../../../src/types';
@@ -10,9 +11,8 @@ 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;
const pushReservationToAirtrail = vi.fn().mockResolvedValue(undefined);
const airtrailLink = { pushReservationToAirtrail } as unknown as AirtrailLinkService;
function makeService(overrides: Partial<ReservationsService> = {}): ReservationsService {
return {
@@ -29,6 +29,20 @@ function makeService(overrides: Partial<ReservationsService> = {}): Reservations
} as unknown as ReservationsService;
}
/**
* The route over the real booking use cases (ReservationWritesService) with the
* service stubbed, so these cases pin what the route writes and sends, not how
* it calls into the use case. The realtime transport is the stub's `broadcast`.
*/
function controllerOver(svc: ReservationsService): ReservationsController {
const writes = Object.assign(Object.create(ReservationWritesService.prototype) as ReservationWritesService, {
reservations: svc,
airtrailLink,
realtime: { broadcast: (svc as unknown as { broadcast: unknown }).broadcast },
});
return new ReservationsController(svc, writes);
}
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
try {
await fn();
@@ -43,7 +57,7 @@ async function thrown(fn: () => unknown): Promise<{ status: number; body: unknow
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 }] });
expect(await controllerOver(svc).list(user, '5')).toEqual({ reservations: [{ id: 1 }] });
});
describe('POST /', () => {
@@ -55,29 +69,24 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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 broadcast = vi.fn();
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({
expect(await controllerOver(svc).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']);
// Every event skips the socket that sent the request, the cost between the stay and the booking.
expect(broadcast.mock.calls).toEqual([
['5', 'accommodation:created', {}, 'sock'],
['5', 'budget:created', { item: { id: 3 } }, 'sock'],
['5', 'reservation:created', { reservation: { id: 9 } }, 'sock'],
]);
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging');
});
@@ -88,7 +97,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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({
expect(await thrown(() => controllerOver(svc).create(user, '5', body))).toEqual({
status: 400,
body: { error: 'Not part of this trip: accommodation_id' },
});
@@ -104,7 +113,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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({
expect(await thrown(() => controllerOver(svc).create(user, '5', body))).toEqual({
status: 400,
body: { error: 'Unknown reference: place_id' },
});
@@ -117,11 +126,10 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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' } });
expect(await thrown(() => controllerOver(svc).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
@@ -140,7 +148,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
type: 'hotel',
create_budget_entry: { total_price: 801.76, currency: 'usd' },
};
await new ReservationsController(svc, airtrailLink).create(user, '5', body, 'sock');
await controllerOver(svc).create(user, '5', body, 'sock');
expect(withFrozenRate).toHaveBeenCalledWith('5', { total_price: 801.76, currency: 'usd' });
expect(createWithCost).toHaveBeenCalledWith('5', body, {
total_price: 801.76,
@@ -159,14 +167,9 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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(await controllerOver(svc).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');
});
@@ -175,36 +178,78 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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' } });
expect(await thrown(() => controllerOver(svc).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 updateWithCost = vi.fn().mockReturnValue({
reservation: { id: 9 },
accommodationChanged: true,
accommodationCreated: false,
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(await controllerOver(svc).update(user, '5', '9', body, 'sock')).toEqual({ reservation: { id: 9 } });
expect(updateWithCost).toHaveBeenCalledWith('9', '5', body, current, { total_price: 50 });
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', {}, 'sock');
expect(announceCost).toHaveBeenCalledWith('5', costEvents, 'sock');
expect(broadcast.mock.calls).toEqual([
['5', 'accommodation:updated', {}, 'sock'],
['5', 'budget:updated', { item: { id: 3 } }, 'sock'],
['5', 'reservation:updated', { reservation: { id: 9 } }, 'sock'],
]);
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Old', 'lodging');
});
// G6: the hotel link tool makes this same edit, and both name a stay written for the first time created.
it('announces a stay the edit wrote for the first time as created, not as updated', async () => {
const broadcast = vi.fn();
const svc = makeService({
getReservation: vi.fn().mockReturnValue({ title: 'Hotel', type: 'hotel' }),
updateWithCost: vi.fn().mockReturnValue({
reservation: { id: 9 },
accommodationChanged: true,
accommodationCreated: true,
costEvents: [],
}),
broadcast,
} as Partial<ReservationsService>);
await controllerOver(svc).update(user, '5', '9', { type: 'hotel' }, 'sock');
expect(broadcast.mock.calls.map((c) => c[1])).toEqual(['accommodation:created', 'reservation:updated']);
});
it('pushes a linked AirTrail flight back after the write, and leaves an unlinked one alone', async () => {
pushReservationToAirtrail.mockClear();
const linked = { id: 9, external_source: 'airtrail', sync_enabled: 1 };
const updateWithCost = vi
.fn()
.mockReturnValueOnce({ reservation: linked, accommodationChanged: false, costEvents: [] })
.mockReturnValueOnce({
reservation: { ...linked, sync_enabled: 0 },
accommodationChanged: false,
costEvents: [],
});
const svc = makeService({
getReservation: vi.fn().mockReturnValue({ title: 'AY1502', type: 'flight' }),
updateWithCost,
} as Partial<ReservationsService>);
await controllerOver(svc).update(user, '5', '9', { notes: 'Gate B' }, 'sock');
await controllerOver(svc).update(user, '5', '9', { notes: 'Gate C' }, 'sock');
expect(pushReservationToAirtrail.mock.calls).toEqual([[9, 5]]);
});
it('400s on a body id belonging to another trip, without writing', async () => {
const update = vi.fn();
const svc = makeService({
@@ -212,11 +257,10 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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(await thrown(() => controllerOver(svc).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();
});
@@ -229,11 +273,10 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
} 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(await thrown(() => controllerOver(svc).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();
});
});
@@ -244,11 +287,10 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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' } });
expect(await thrown(() => controllerOver(svc).updateTravelers(user, '5', '9', { user_ids: [1] }))).toEqual({
status: 404,
body: { error: 'Reservation not found' },
});
});
it('assigns travelers, broadcasts, and returns { travelers, reservation }', async () => {
@@ -257,9 +299,10 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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(await controllerOver(svc).updateTravelers(user, '5', '9', { user_ids: [2] }, 'sock')).toEqual({
travelers,
reservation,
});
expect(setTravelers).toHaveBeenCalledWith('9', '5', [2]);
expect(broadcast).toHaveBeenCalledWith(
'5',
@@ -282,7 +325,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
}),
broadcast,
} as Partial<ReservationsService>);
expect(await thrown(() => new ReservationsController(svc, airtrailLink).remove(user, '5', '9'))).toEqual({
expect(await thrown(() => controllerOver(svc).remove(user, '5', '9'))).toEqual({
status: 404,
body: { error: 'Reservation not found' },
});
@@ -299,12 +342,14 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
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({
expect(await controllerOver(svc).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(broadcast.mock.calls).toEqual([
['5', 'accommodation:deleted', { accommodationId: 3 }, 'sock'],
['5', 'budget:deleted', { itemId: 7 }, 'sock'],
['5', 'reservation:deleted', { reservationId: 9 }, 'sock'],
]);
expect(notifyBookingChange).toHaveBeenCalledWith('5', user.id, 'Hotel', 'lodging');
});
@@ -317,7 +362,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
});
const broadcast = vi.fn();
const svc = makeService({ remove, broadcast } as Partial<ReservationsService>);
await new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock');
await controllerOver(svc).remove(user, '5', '9', 'sock');
expect(broadcast.mock.calls).toEqual([
['5', 'budget:deleted', { itemId: 7 }, 'sock'],
['5', 'budget:deleted', { itemId: 8 }, 'sock'],
@@ -334,7 +379,7 @@ describe('ReservationsController (parity with the legacy /api/trips/:tripId/rese
});
const broadcast = vi.fn();
const svc = makeService({ remove, broadcast } as Partial<ReservationsService>);
await new ReservationsController(svc, airtrailLink).remove(user, '5', '9', 'sock');
await controllerOver(svc).remove(user, '5', '9', 'sock');
expect(broadcast.mock.calls).toEqual([['5', 'reservation:deleted', { reservationId: 9 }, 'sock']]);
});
});
@@ -41,6 +41,8 @@ function build(
opts: {
canEdit?: boolean;
cascade?: boolean;
/** The update wrote the booking's stay for the first time. */
created?: boolean;
stop?: boolean;
seenActions?: string[];
unresolved?: string[];
@@ -54,7 +56,12 @@ function build(
const reservations = {
createWithCost: vi.fn(() => ({ reservation: { id: 40 }, accommodationCreated: !!opts.cascade, costEvents: [] })),
getReservation: vi.fn((id: string) => (id === '5' ? { id: 5, title: 'Hotel', type: 'lodging' } : undefined)),
updateWithCost: vi.fn(() => ({ reservation: { id: 5 }, accommodationChanged: !!opts.cascade, costEvents: [] })),
updateWithCost: vi.fn(() => ({
reservation: { id: 5 },
accommodationChanged: !!opts.cascade || !!opts.created,
accommodationCreated: !!opts.created,
costEvents: [],
})),
remove: vi.fn((id: string) =>
id === '5'
? {
@@ -279,6 +286,17 @@ describe('ReservationsRpc', () => {
expect(events(f.realtime)).toEqual(['reservation:deleted']);
});
it('BOOK-RPC-022 an update that writes the stay for the first time announces it as created, as REST does', async () => {
const f = build({ created: true });
await f
.host()
.dispatch(
req('reservations.update', { tripId: 1, reservationId: 5, input: { title: 'x', type: 'hotel' } }),
42,
);
expect(events(f.realtime)).toEqual(['accommodation:created', 'reservation:updated']);
});
it('BOOK-RPC-008e an update with a malformed endpoints array is BAD_PARAMS too', async () => {
const f = build();
const res = (await f.host().dispatch(