mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
1600 lines
63 KiB
TypeScript
1600 lines
63 KiB
TypeScript
/**
|
|
* Unit tests for MCP transport tools: create_transport, update_transport, delete_transport.
|
|
* Focus: flight endpoints supplied with only an IATA `code` are backfilled with
|
|
* lat/lng/timezone from the airport database (the columns are NOT NULL), and
|
|
* endpoints that can't be resolved produce a clean error instead of a SQL crash.
|
|
*/
|
|
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 { 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';
|
|
import { FakeRealtimeService } from '../../helpers/fake-realtime';
|
|
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
|
|
import { oneByOne } from '../../helpers/one-by-one';
|
|
import { resetTestDb } from '../../helpers/test-db';
|
|
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
|
|
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
|
|
|
vi.mock('../../../src/db/database', async () => {
|
|
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
|
|
return buildDbMock(createSnapshotTestDb());
|
|
});
|
|
|
|
const realtime = new FakeRealtimeService();
|
|
const broadcastMock = realtime.broadcastMock;
|
|
|
|
/** Overwrites the booking's stored metadata text, the way an import or a legacy row leaves it. */
|
|
async function setMetadata(metadata: string, reservationId: number): Promise<void> {
|
|
await updateRows(orm, Reservations, { id: reservationId }, { metadata });
|
|
}
|
|
|
|
beforeEach(() => {
|
|
resetTestDb(testDb);
|
|
broadcastMock.mockClear();
|
|
delete process.env.DEMO_MODE;
|
|
});
|
|
|
|
let orm: TestOrm;
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(testDb);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await orm.close();
|
|
testDb.close();
|
|
});
|
|
|
|
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
|
|
const h = await createMcpHarness({ realtime, userId, withResources: false });
|
|
try {
|
|
await fn(h);
|
|
} finally {
|
|
await h.cleanup();
|
|
}
|
|
}
|
|
|
|
const flightEndpoints = [
|
|
{ role: 'from', sequence: 0, name: 'Zurich', code: 'ZRH' },
|
|
{ role: 'to', sequence: 1, name: 'Paris CDG', code: 'CDG' },
|
|
];
|
|
|
|
// The booking from the report (#1914): AMS → CDG → FCO with a stop in Paris.
|
|
const stopoverEndpoints = [
|
|
{ role: 'from', sequence: 0, name: 'Amsterdam (AMS)', code: 'AMS' },
|
|
{ role: 'stop', sequence: 1, name: 'Paris (CDG)', code: 'CDG' },
|
|
{ role: 'to', sequence: 2, name: 'Rome (FCO)', code: 'FCO' },
|
|
];
|
|
|
|
const errorText = (result: unknown) => (result as { content: { text: string }[] }).content[0].text;
|
|
const storedMetadata = async (reservationId: number) =>
|
|
JSON.parse(String((await findRow(orm, Reservations, { id: reservationId }))?.metadata));
|
|
|
|
/** The ids of the booking's stored endpoints, in sequence order. */
|
|
const endpointIds = async (reservationId: number) =>
|
|
(await findRows(orm, ReservationEndpoints, { reservation: reservationId }, { sequence: 'asc' })).map((r) => r.id);
|
|
|
|
/** A second trip's day, which the tools must refuse as a foreign day. */
|
|
const foreignDayOn = async (tripId: number, date: string) => (await makeDay(orm, tripId, { date, day_number: 1 })).id;
|
|
|
|
describe('Tool: create_transport', () => {
|
|
it('backfills lat/lng/timezone for code-only flight endpoints', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
const eps = data.reservation.endpoints;
|
|
expect(eps).toHaveLength(2);
|
|
const from = eps.find((e: any) => e.role === 'from');
|
|
expect(typeof from.lat).toBe('number');
|
|
expect(typeof from.lng).toBe('number');
|
|
expect(from.timezone).toBe('Europe/Zurich');
|
|
// persisted NOT NULL columns are populated
|
|
const rows = await findRows(orm, ReservationEndpoints, { reservation: data.reservation.id });
|
|
expect(rows.every((r) => r.lat != null && r.lng != null)).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('keeps manually-supplied coordinates and the caller timezone', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'train',
|
|
title: 'Scenic train',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'Station A', lat: 46.0, lng: 7.0, timezone: 'Europe/Zurich' },
|
|
{ role: 'to', sequence: 1, name: 'Station B', lat: 46.5, lng: 7.5 },
|
|
],
|
|
},
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
const from = data.reservation.endpoints.find((e: any) => e.role === 'from');
|
|
expect(from.lat).toBe(46.0);
|
|
expect(from.timezone).toBe('Europe/Zurich');
|
|
});
|
|
});
|
|
|
|
it('errors on an unresolvable airport code instead of crashing', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'Bad flight',
|
|
endpoints: [{ role: 'from', sequence: 0, name: 'Nowhere', code: 'ZZZ' }],
|
|
},
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect((result.content as any)[0].text).toContain('ZZZ');
|
|
});
|
|
});
|
|
|
|
it('errors on an endpoint missing both coordinates and a code', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'car',
|
|
title: 'Road trip',
|
|
endpoints: [{ role: 'from', sequence: 0, name: 'My house' }],
|
|
},
|
|
});
|
|
expect(result.isError).toBe(true);
|
|
expect((result.content as any)[0].text).toContain('missing coordinates');
|
|
});
|
|
});
|
|
|
|
it('creates a transport with no endpoints', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'flight', title: 'TBD flight' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.title).toBe('TBD flight');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: update_transport', () => {
|
|
it('backfills coords when replacing endpoints', async () => {
|
|
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: 'flight', title: 'F', endpoints: flightEndpoints },
|
|
}),
|
|
) as any;
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'JFK', code: 'JFK' },
|
|
{ role: 'to', sequence: 1, name: 'Zurich', code: 'ZRH' },
|
|
],
|
|
},
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
const from = data.reservation.endpoints.find((e: any) => e.role === 'from');
|
|
expect(from.code).toBe('JFK');
|
|
expect(typeof from.lat).toBe('number');
|
|
});
|
|
});
|
|
|
|
it('leaves endpoints untouched when not provided', async () => {
|
|
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: 'flight', title: 'F', endpoints: flightEndpoints },
|
|
}),
|
|
) as any;
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: created.reservation.id, status: 'confirmed' },
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.status).toBe('confirmed');
|
|
expect(data.reservation.endpoints).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
it('re-files a linked expense on a type change in the same write, as REST and the plugin RPC do', async () => {
|
|
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: 'flight', title: 'F', endpoints: flightEndpoints },
|
|
}),
|
|
) as { reservation: { id: number } };
|
|
const auto = (
|
|
await makeBudgetItem(orm, trip.id, {
|
|
name: 'Fare',
|
|
category: 'flights',
|
|
total_price: 100,
|
|
reservation: created.reservation.id,
|
|
})
|
|
).id;
|
|
const picked = (
|
|
await makeBudgetItem(orm, trip.id, {
|
|
name: 'Lounge',
|
|
category: 'food',
|
|
total_price: 20,
|
|
reservation: created.reservation.id,
|
|
})
|
|
).id;
|
|
broadcastMock.mockClear();
|
|
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: created.reservation.id, type: 'train' },
|
|
});
|
|
expect((parseToolResult(result) as { reservation: { type: string } }).reservation.type).toBe('train');
|
|
|
|
// flight -> train moves the auto-derived category; the hand-picked one stays.
|
|
const category = async (id: number) => (await findRow(orm, BudgetItems, { id }))!.category;
|
|
expect(await category(auto)).toBe('transport');
|
|
expect(await category(picked)).toBe('food');
|
|
const updated = broadcastMock.mock.calls
|
|
.filter((c) => c[1] === 'budget:updated')
|
|
.map((c) => (c[2] as { item: { id: number } }).item.id);
|
|
expect(updated).toEqual([auto]);
|
|
// The expense goes out after the commit, before the booking.
|
|
const events = broadcastMock.mock.calls.map((c) => c[1]);
|
|
expect(events.indexOf('budget:updated')).toBeLessThan(events.indexOf('reservation:updated'));
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Guards, the budget link and delete
|
|
//
|
|
// These paths were never covered: the tools lived in src/mcp/, which the
|
|
// coverage gate does not measure. Moving them into reservations.mcp.ts put them
|
|
// inside src/nest/** and made the gap visible.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Transport tools: access and validation', () => {
|
|
it('create_transport refuses a trip the caller cannot see', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb, { username: 'stranger' });
|
|
const trip = createTrip(testDb, stranger.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG' },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toContain('Trip not found');
|
|
});
|
|
});
|
|
|
|
it('create_transport rejects a start_day_id from another trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const otherTrip = createTrip(testDb, user.id);
|
|
const foreignDay = await foreignDayOn(otherTrip.id, '2026-07-01');
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'train', title: 'ICE', start_day_id: foreignDay },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toBe(
|
|
'start_day_id does not belong to this trip.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('create_transport rejects an end_day_id from another trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const otherTrip = createTrip(testDb, user.id);
|
|
const foreignDay = await foreignDayOn(otherTrip.id, '2026-07-02');
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'train', title: 'ICE', end_day_id: foreignDay },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toBe(
|
|
'end_day_id does not belong to this trip.',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Transport tools: the price link', () => {
|
|
it('create_transport with a price creates the budget item and broadcasts it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', price: 240, budget_category: 'Flights' },
|
|
});
|
|
const data = parseToolResult(result) as { reservation: { id: number } };
|
|
|
|
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
|
|
expect(item).toMatchObject({ name: 'ZRH → CDG', category: 'Flights', total_price: 240 });
|
|
// The price also rides along in the reservation metadata, as the REST path does.
|
|
expect(broadcastMock.mock.calls.some((c) => c[1] === 'budget:created')).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('create_transport falls back to the transport type as the budget category', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'train', title: 'ICE 599', price: 89 },
|
|
});
|
|
const data = parseToolResult(result) as { reservation: { id: number } };
|
|
const item = await findRow(orm, BudgetItems, { reservation: data.reservation.id });
|
|
expect(item?.category).toBe('train');
|
|
});
|
|
});
|
|
|
|
it('create_transport with price 0 records no budget item', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'car', title: 'Rental', price: 0 },
|
|
});
|
|
const data = parseToolResult(result) as { reservation: { id: number } };
|
|
expect(await findRow(orm, BudgetItems, { reservation: data.reservation.id })).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: update_transport (guards)', () => {
|
|
it('404s an unknown reservation id', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: 999999, title: 'nope' },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toBe('Transport not found.');
|
|
});
|
|
});
|
|
|
|
it('refuses a reservation that is not a transport type', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_reservation',
|
|
arguments: { tripId: trip.id, type: 'restaurant', title: 'Dinner' },
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, title: 'still dinner' },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toBe(
|
|
'Reservation is not a transport type. Use update_reservation instead.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('rejects a start_day_id from another trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const otherTrip = createTrip(testDb, user.id);
|
|
const foreignDay = await foreignDayOn(otherTrip.id, '2026-08-01');
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints },
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, start_day_id: foreignDay },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toBe(
|
|
'start_day_id does not belong to this trip.',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('errors on an unresolvable airport code when replacing endpoints', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints },
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: reservation.id,
|
|
endpoints: [{ role: 'from', sequence: 0, name: 'Nowhere', code: 'QQQ' }],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toContain('Could not resolve airport code');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: delete_transport', () => {
|
|
it('deletes the booking and broadcasts it', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints },
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
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 findRow(orm, Reservations, { id: reservation.id })).toBeNull();
|
|
expect(broadcastMock.mock.calls.some((c) => c[1] === 'reservation:deleted')).toBe(true);
|
|
expect(broadcastMock.mock.calls.some((c) => c[1] === 'budget:deleted')).toBe(false);
|
|
});
|
|
});
|
|
|
|
it('takes every expense linked to the transport with it and announces each one (#2084)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'flight', title: 'ZRH → CDG', endpoints: flightEndpoints },
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
const expense = async (name: string) =>
|
|
(
|
|
await makeBudgetItem(orm, trip.id, {
|
|
name,
|
|
category: 'flights',
|
|
total_price: 50,
|
|
reservation: reservation.id,
|
|
})
|
|
).id;
|
|
const fare = await expense('Fare');
|
|
const seat = await expense('Seat');
|
|
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, BudgetItems, { trip: trip.id })).toBe(0);
|
|
// One event per expense, and all of them before the transport itself goes.
|
|
expect(broadcastMock.mock.calls.map((c) => c[1])).toEqual([
|
|
'budget:deleted',
|
|
'budget:deleted',
|
|
'reservation:deleted',
|
|
]);
|
|
expect(broadcastMock.mock.calls.slice(0, 2).map((c) => (c[2] as { itemId: number }).itemId)).toEqual([
|
|
fare,
|
|
seat,
|
|
]);
|
|
});
|
|
});
|
|
|
|
// 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);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'delete_transport',
|
|
arguments: { tripId: trip.id, reservationId: 999999 },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toBe('Transport not found.');
|
|
});
|
|
});
|
|
|
|
it('refuses a trip the caller cannot see', async () => {
|
|
const { user } = createUser(testDb);
|
|
const { user: stranger } = createUser(testDb, { username: 'stranger2' });
|
|
const trip = createTrip(testDb, stranger.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'delete_transport',
|
|
arguments: { tripId: trip.id, reservationId: 1 },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect((result as { content: { text: string }[] }).content[0].text).toContain('Trip not found');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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' });
|
|
await oneByOne([linked, local], (reservationId) =>
|
|
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)
|
|
//
|
|
// The per-segment times of a stopover live in metadata.legs; the endpoints only
|
|
// carry the onward departure. Before this the tools pinned metadata to string
|
|
// values and had no legs parameter, so a stopover created over MCP showed the
|
|
// stop's departure as its arrival too.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Tool: create_transport (multi-leg)', () => {
|
|
it('stores the per-leg times of the reported AMS to CDG to FCO stopover', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id, { date: '2026-02-04' });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_day_id: day.id, dep_time: '09:00', arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect((await storedMetadata(data.reservation.id)).legs).toEqual([
|
|
{ from: 'AMS', to: 'CDG', dep_day_id: day.id, dep_time: '09:00', arr_day_id: null, arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_day_id: null, dep_time: '11:00', arr_day_id: null, arr_time: '12:00' },
|
|
]);
|
|
// Blank endpoint times are filled the way the form writes them: the onward
|
|
// departure on every waypoint but the last, the arrival on the last.
|
|
expect(data.reservation.endpoints.map((e: any) => e.local_time)).toEqual(['09:00', '11:00', '12:00']);
|
|
expect(data.reservation.endpoints[0].local_date).toBe('2026-02-04');
|
|
// The span follows the first and last leg.
|
|
expect(data.reservation.day_id).toBe(day.id);
|
|
expect(data.reservation.reservation_time).toBe('2026-02-04T09:00');
|
|
expect(data.reservation.reservation_end_time).toBe('12:00');
|
|
});
|
|
});
|
|
|
|
it('mirrors the flat metadata keys off the first and last leg', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', airline: 'KLM', flight_number: 'KL1233', seat: '14A' },
|
|
{ from: 'CDG', to: 'FCO', airline: 'Air France', flight_number: 'AF1204' },
|
|
],
|
|
},
|
|
}),
|
|
) as any;
|
|
const meta = await storedMetadata(data.reservation.id);
|
|
expect(meta.departure_airport).toBe('AMS');
|
|
expect(meta.arrival_airport).toBe('FCO');
|
|
expect(meta.airline).toBe('KLM');
|
|
expect(meta.flight_number).toBe('KL1233');
|
|
expect(meta.seat).toBe('14A');
|
|
});
|
|
});
|
|
|
|
it('stores a per-segment booking reference and leaves the booking-level one alone (#1943)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', confirmation_number: 'ABC123' },
|
|
{ from: 'CDG', to: 'FCO' },
|
|
],
|
|
},
|
|
}),
|
|
) as any;
|
|
const meta = await storedMetadata(data.reservation.id);
|
|
expect(meta.legs[0].confirmation_number).toBe('ABC123');
|
|
// A leg without one gets no key at all, rather than an undefined that
|
|
// serialises away and back as null.
|
|
expect(Object.keys(meta.legs[1])).not.toContain('confirmation_number');
|
|
// The reference of a single segment must never become the booking's: that
|
|
// is a column of its own, fed only by the tool's own parameter.
|
|
expect(meta.confirmation_number).toBeUndefined();
|
|
expect(data.reservation.confirmation_number).toBeNull();
|
|
});
|
|
});
|
|
|
|
it('never overwrites a flat key the caller set itself', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
metadata: { airline: 'KLM Cityhopper' },
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', airline: 'KLM' },
|
|
{ from: 'CDG', to: 'FCO', airline: 'Air France' },
|
|
],
|
|
},
|
|
}),
|
|
) as any;
|
|
const meta = await storedMetadata(data.reservation.id);
|
|
expect(meta.airline).toBe('KLM Cityhopper');
|
|
expect(meta.legs[0].airline).toBe('KLM');
|
|
});
|
|
});
|
|
|
|
it('fills from/to from the endpoint codes when the leg omits them', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ dep_time: '09:00', arr_time: '10:00' },
|
|
{ dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
}),
|
|
) as any;
|
|
const meta = await storedMetadata(data.reservation.id);
|
|
expect(meta.legs.map((l: any) => [l.from, l.to])).toEqual([
|
|
['AMS', 'CDG'],
|
|
['CDG', 'FCO'],
|
|
]);
|
|
});
|
|
});
|
|
|
|
it('accepts a multi-leg train and mirrors its flat keys', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const data = parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'train',
|
|
title: 'Basel to Milano',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'Basel SBB', lat: 47.5, lng: 7.6 },
|
|
{ role: 'stop', sequence: 1, name: 'Lugano', lat: 46.0, lng: 8.9 },
|
|
{ role: 'to', sequence: 2, name: 'Milano Centrale', lat: 45.5, lng: 9.2 },
|
|
],
|
|
legs: [
|
|
{ train_number: 'EC 57', platform: '8', dep_time: '08:33', arr_time: '11:20' },
|
|
{ train_number: 'EC 317', platform: '3', dep_time: '11:40', arr_time: '12:55' },
|
|
],
|
|
},
|
|
}),
|
|
) as any;
|
|
const meta = await storedMetadata(data.reservation.id);
|
|
// Station endpoints carry no code, so the labels come from their names.
|
|
expect(meta.legs.map((l: any) => [l.from, l.to])).toEqual([
|
|
['Basel SBB', 'Lugano'],
|
|
['Lugano', 'Milano Centrale'],
|
|
]);
|
|
expect(meta.train_number).toBe('EC 57');
|
|
expect(meta.platform).toBe('8');
|
|
expect(data.reservation.endpoints.map((e: any) => e.local_time)).toEqual(['08:33', '11:40', '12:55']);
|
|
});
|
|
});
|
|
|
|
it('rejects a leg count that does not match the endpoints', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'ZRH to CDG',
|
|
endpoints: flightEndpoints,
|
|
legs: [
|
|
{ from: 'ZRH', to: 'CDG' },
|
|
{ from: 'CDG', to: 'FCO' },
|
|
],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toBe(
|
|
'legs must contain exactly one entry fewer than endpoints (got 2 legs for 2 endpoints).',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('rejects a single leg, which no reader would treat as multi-segment', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'ZRH to CDG',
|
|
endpoints: flightEndpoints,
|
|
legs: [{ from: 'ZRH', to: 'CDG', dep_time: '09:00', arr_time: '10:00' }],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toContain('at least 2 entries');
|
|
});
|
|
});
|
|
|
|
it('rejects legs on a transport type that has none', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'car',
|
|
title: 'Rental',
|
|
legs: [
|
|
{ from: 'A', to: 'B' },
|
|
{ from: 'B', to: 'C' },
|
|
],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toBe('legs are only supported for flight and train bookings.');
|
|
});
|
|
});
|
|
|
|
it('rejects a leg day that belongs to another trip', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const otherTrip = createTrip(testDb, user.id);
|
|
const foreignDay = createDay(testDb, otherTrip.id, { date: '2026-02-04' });
|
|
await withHarness(user.id, async (h) => {
|
|
const depResult = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_day_id: foreignDay.id },
|
|
{ from: 'CDG', to: 'FCO' },
|
|
],
|
|
},
|
|
});
|
|
expect(errorText(depResult)).toBe('legs[0].dep_day_id does not belong to this trip.');
|
|
|
|
const arrResult = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG' },
|
|
{ from: 'CDG', to: 'FCO', arr_day_id: foreignDay.id },
|
|
],
|
|
},
|
|
});
|
|
expect(errorText(arrResult)).toBe('legs[1].arr_day_id does not belong to this trip.');
|
|
});
|
|
});
|
|
|
|
it('rejects a leg airport that contradicts its endpoint', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const wrongTo = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'FCO' },
|
|
{ from: 'CDG', to: 'FCO' },
|
|
],
|
|
},
|
|
});
|
|
expect(errorText(wrongTo)).toBe('legs[0].to (FCO) does not match endpoints[1] (CDG).');
|
|
|
|
const wrongFrom = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG' },
|
|
{ from: 'ORY', to: 'FCO' },
|
|
],
|
|
},
|
|
});
|
|
expect(errorText(wrongFrom)).toBe('legs[1].from (ORY) does not match endpoints[1] (CDG).');
|
|
});
|
|
});
|
|
|
|
it('rejects a leg time that contradicts an endpoint time already set', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const withTimes = [
|
|
{ ...stopoverEndpoints[0], local_time: '09:00' },
|
|
{ ...stopoverEndpoints[1], local_time: '11:30' },
|
|
{ ...stopoverEndpoints[2], local_time: '12:30' },
|
|
];
|
|
await withHarness(user.id, async (h) => {
|
|
const depClash = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: withTimes,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:30' },
|
|
],
|
|
},
|
|
});
|
|
expect(errorText(depClash)).toContain('legs[1].dep_time (11:00) does not match endpoints[1].local_time (11:30)');
|
|
|
|
const arrClash = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: withTimes,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:30', arr_time: '12:00' },
|
|
],
|
|
},
|
|
});
|
|
expect(errorText(arrClash)).toContain('legs[1].arr_time (12:00) does not match endpoints[2].local_time (12:30)');
|
|
});
|
|
});
|
|
|
|
it('rejects a span that contradicts the first or last leg', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const dayOne = createDay(testDb, trip.id, { date: '2026-02-04' });
|
|
const dayTwo = createDay(testDb, trip.id, { date: '2026-02-05' });
|
|
await withHarness(user.id, async (h) => {
|
|
const startClash = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
start_day_id: dayOne.id,
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_day_id: dayTwo.id },
|
|
{ from: 'CDG', to: 'FCO' },
|
|
],
|
|
},
|
|
});
|
|
expect(errorText(startClash)).toBe(
|
|
`start_day_id (${dayOne.id}) does not match legs[0].dep_day_id (${dayTwo.id}).`,
|
|
);
|
|
|
|
const endClash = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
end_day_id: dayOne.id,
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG' },
|
|
{ from: 'CDG', to: 'FCO', arr_day_id: dayTwo.id },
|
|
],
|
|
},
|
|
});
|
|
expect(errorText(endClash)).toBe(`end_day_id (${dayOne.id}) does not match legs[1].arr_day_id (${dayTwo.id}).`);
|
|
});
|
|
});
|
|
|
|
it('refuses segments smuggled through metadata.legs and names the legs parameter', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
metadata: { legs: '[{"from":"AMS","to":"CDG"}]' },
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toContain('in the legs parameter, not in metadata.legs');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Tool: update_transport (multi-leg)', () => {
|
|
const seedStopover = async (h: McpHarness, tripId: number) =>
|
|
parseToolResult(
|
|
await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId,
|
|
type: 'flight',
|
|
title: 'AMS to FCO',
|
|
endpoints: stopoverEndpoints,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
}),
|
|
) as any;
|
|
|
|
it('keeps the stored metadata and the day-plan positions when only legs are sent', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await seedStopover(h, trip.id);
|
|
// What an AirTrail import leaves behind: sync ids plus per-leg planner positions.
|
|
await setMetadata(
|
|
JSON.stringify({
|
|
departure_airport: 'AMS',
|
|
arrival_airport: 'FCO',
|
|
airtrail_ids: ['17', '18'],
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00', day_positions: { 5: 2 } },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
}),
|
|
created.reservation.id,
|
|
);
|
|
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:15' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
});
|
|
const meta = await storedMetadata((parseToolResult(result) as any).reservation.id);
|
|
expect(meta.airtrail_ids).toEqual(['17', '18']);
|
|
expect(meta.departure_airport).toBe('AMS');
|
|
expect(meta.legs[0].arr_time).toBe('10:15');
|
|
expect(meta.legs[0].day_positions).toEqual({ 5: 2 });
|
|
expect(meta.legs[1].day_positions).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('keeps a per-segment booking reference through a legs-only update (#1943)', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await seedStopover(h, trip.id);
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00', confirmation_number: 'ABC123' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00', confirmation_number: 'XYZ789' },
|
|
],
|
|
},
|
|
});
|
|
const meta = await storedMetadata((parseToolResult(result) as any).reservation.id);
|
|
// The copy-through list in applyLegs is what carries the field: a field
|
|
// missing from it is accepted by the schema and then silently dropped.
|
|
expect(meta.legs.map((l: any) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
|
|
expect(meta.confirmation_number).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it('uses the supplied metadata as the base when both are sent', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await seedStopover(h, trip.id);
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
metadata: { confirmation_source: 'email' },
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
});
|
|
const meta = await storedMetadata((parseToolResult(result) as any).reservation.id);
|
|
expect(meta.confirmation_source).toBe('email');
|
|
expect(meta.legs).toHaveLength(2);
|
|
// departure_airport is mirrored back in because the supplied metadata dropped it.
|
|
expect(meta.departure_airport).toBe('AMS');
|
|
});
|
|
});
|
|
|
|
it('leaves the metadata alone when neither legs nor metadata are sent', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await seedStopover(h, trip.id);
|
|
const before = await storedMetadata(created.reservation.id);
|
|
await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: created.reservation.id, status: 'confirmed' },
|
|
});
|
|
expect(await storedMetadata(created.reservation.id)).toEqual(before);
|
|
});
|
|
});
|
|
|
|
it('counts the stored endpoints when the caller does not replace them', async () => {
|
|
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: 'flight', title: 'ZRH to CDG', endpoints: flightEndpoints },
|
|
}),
|
|
) as any;
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
legs: [
|
|
{ from: 'ZRH', to: 'CDG' },
|
|
{ from: 'CDG', to: 'FCO' },
|
|
],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toBe(
|
|
'legs must contain exactly one entry fewer than endpoints (got 2 legs for 2 endpoints).',
|
|
);
|
|
});
|
|
});
|
|
|
|
it('leaves the endpoint rows untouched when the legs change nothing about them', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await seedStopover(h, trip.id);
|
|
const idsBefore = await endpointIds(created.reservation.id);
|
|
await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:30' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
});
|
|
const idsAfter = await endpointIds(created.reservation.id);
|
|
expect(idsAfter).toEqual(idsBefore);
|
|
});
|
|
});
|
|
|
|
it('fills the endpoint times when the stored endpoints have none', async () => {
|
|
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: 'flight', title: 'AMS to FCO', endpoints: stopoverEndpoints },
|
|
}),
|
|
) as any;
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.endpoints.map((e: any) => e.local_time)).toEqual(['09:00', '11:00', '12:00']);
|
|
});
|
|
});
|
|
|
|
it('survives a booking whose stored metadata is not valid JSON', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await seedStopover(h, trip.id);
|
|
await setMetadata('not json at all', created.reservation.id);
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBeFalsy();
|
|
expect((await storedMetadata((parseToolResult(result) as any).reservation.id)).legs).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
it('recovers legs from double-encoded metadata instead of losing the positions', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await seedStopover(h, trip.id);
|
|
await setMetadata(
|
|
JSON.stringify(
|
|
JSON.stringify({
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', day_positions: { 9: 1 } },
|
|
{ from: 'CDG', to: 'FCO' },
|
|
],
|
|
}),
|
|
),
|
|
created.reservation.id,
|
|
);
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
legs: [
|
|
{ from: 'AMS', to: 'CDG', dep_time: '09:00', arr_time: '10:00' },
|
|
{ from: 'CDG', to: 'FCO', dep_time: '11:00', arr_time: '12:00' },
|
|
],
|
|
},
|
|
});
|
|
expect((await storedMetadata((parseToolResult(result) as any).reservation.id)).legs[0].day_positions).toEqual({
|
|
9: 1,
|
|
});
|
|
});
|
|
});
|
|
|
|
it('rejects legs on a booking that is not a flight or train', async () => {
|
|
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: 'car', title: 'Rental' },
|
|
}),
|
|
) as any;
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
legs: [
|
|
{ from: 'A', to: 'B' },
|
|
{ from: 'B', to: 'C' },
|
|
],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toBe('legs are only supported for flight and train bookings.');
|
|
});
|
|
});
|
|
|
|
it('refuses segments smuggled through metadata.legs', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await seedStopover(h, trip.id);
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
reservationId: created.reservation.id,
|
|
metadata: { legs: '[{"from":"AMS","to":"CDG"}]' },
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toContain('in the legs parameter, not in metadata.legs');
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The full type list
|
|
//
|
|
// The planner's transport picker offers ten modes; these tools accepted four, so
|
|
// a bus or a ferry could be planned in the UI and not through an assistant. The
|
|
// cases below pin the whole picker list, and pin that widening it did not also
|
|
// hand legs[] to a mode whose form never writes them.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** client/src/components/Planner/TransportModal.tsx, in the picker's order. */
|
|
const PICKER_TRANSPORT_TYPES = [
|
|
'flight',
|
|
'train',
|
|
'bus',
|
|
'car',
|
|
'taxi',
|
|
'bicycle',
|
|
'cruise',
|
|
'ferry',
|
|
'transport_other',
|
|
] as const;
|
|
|
|
/** The five the tools used to reject outright. */
|
|
const NEWLY_ACCEPTED = ['bus', 'taxi', 'bicycle', 'ferry', 'transport_other'] as const;
|
|
|
|
describe('Transport tools: the full type list', () => {
|
|
it.each(PICKER_TRANSPORT_TYPES)('create_transport accepts %s', async (type) => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type, title: `A ${type} booking` },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBeFalsy();
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.type).toBe(type);
|
|
expect((await findRow(orm, Reservations, { id: data.reservation.id }))?.type).toBe(type);
|
|
});
|
|
});
|
|
|
|
it('stores a bus booking with its stops, times and booking reference', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const depDay = createDay(testDb, trip.id, { day_number: 1 });
|
|
const arrDay = createDay(testDb, trip.id, { day_number: 2 });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'bus',
|
|
title: 'Zurich → Milan',
|
|
start_day_id: depDay.id,
|
|
end_day_id: arrDay.id,
|
|
reservation_time: '22:30',
|
|
reservation_end_time: '06:15',
|
|
confirmation_number: 'FLIX-8891',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'Zurich Sihlquai', lat: 47.3846, lng: 8.5324 },
|
|
{ role: 'to', sequence: 1, name: 'Milano Lampugnano', lat: 45.4936, lng: 9.1177 },
|
|
],
|
|
},
|
|
});
|
|
const data = parseToolResult(result) as any;
|
|
expect(data.reservation.type).toBe('bus');
|
|
expect(data.reservation.confirmation_number).toBe('FLIX-8891');
|
|
expect(data.reservation.endpoints).toHaveLength(2);
|
|
const row = await findRow(orm, Reservations, { id: data.reservation.id });
|
|
expect(row?.day_id).toBe(depDay.id);
|
|
expect(row?.end_day_id).toBe(arrDay.id);
|
|
});
|
|
});
|
|
|
|
it('reports a missing coordinate on a non-flight endpoint rather than failing the insert', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'ferry',
|
|
title: 'Piraeus → Santorini',
|
|
endpoints: [{ role: 'from', sequence: 0, name: 'Piraeus' }],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toContain('missing coordinates');
|
|
});
|
|
});
|
|
|
|
it.each(NEWLY_ACCEPTED)('update_transport accepts %s as the new type', async (type) => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'car', title: 'Rental' },
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, type },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBeFalsy();
|
|
expect((await findRow(orm, Reservations, { id: reservation.id }))?.type).toBe(type);
|
|
});
|
|
});
|
|
|
|
it('update_transport reaches a booking created as a bus', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const created = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'bus', title: 'Night bus' },
|
|
});
|
|
const { reservation } = parseToolResult(created) as { reservation: { id: number } };
|
|
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId: reservation.id, status: 'confirmed' },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBeFalsy();
|
|
expect((await findRow(orm, Reservations, { id: reservation.id }))?.status).toBe('confirmed');
|
|
});
|
|
});
|
|
|
|
it('still refuses a type the picker does not offer', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'teleport', title: 'Nope' },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('refuses to hand-make a transit booking, which create_transit_journey owns', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: { tripId: trip.id, type: 'transit', title: 'Tram 4' },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
});
|
|
});
|
|
|
|
it('still lets update_transport reach a stored transit booking', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
// As create_transit_journey leaves it: a transport row carrying an itinerary.
|
|
const reservationId = await insertRow(orm, Reservations, {
|
|
trip: trip.id,
|
|
title: 'Tram 4',
|
|
type: 'transit',
|
|
status: 'pending',
|
|
});
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'update_transport',
|
|
arguments: { tripId: trip.id, reservationId, status: 'confirmed' },
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBeFalsy();
|
|
const row = await findRow(orm, Reservations, { id: reservationId });
|
|
expect(row).toMatchObject({ status: 'confirmed', type: 'transit' });
|
|
});
|
|
});
|
|
|
|
it('still refuses legs on a mode whose form never writes them', async () => {
|
|
const { user } = createUser(testDb);
|
|
const trip = createTrip(testDb, user.id);
|
|
const day = createDay(testDb, trip.id, { day_number: 1 });
|
|
await withHarness(user.id, async (h) => {
|
|
const result = await h.client.callTool({
|
|
name: 'create_transport',
|
|
arguments: {
|
|
tripId: trip.id,
|
|
type: 'bus',
|
|
title: 'Zurich → Milan via Lugano',
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'Zurich', lat: 47.38, lng: 8.53 },
|
|
{ role: 'stop', sequence: 1, name: 'Lugano', lat: 46.01, lng: 8.96 },
|
|
{ role: 'to', sequence: 2, name: 'Milan', lat: 45.49, lng: 9.11 },
|
|
],
|
|
legs: [
|
|
{ dep_day_id: day.id, dep_time: '22:30', arr_day_id: day.id, arr_time: '00:40' },
|
|
{ dep_day_id: day.id, dep_time: '00:50', arr_day_id: day.id, arr_time: '06:15' },
|
|
],
|
|
},
|
|
});
|
|
expect((result as { isError?: boolean }).isError).toBe(true);
|
|
expect(errorText(result)).toContain('only supported for flight and train');
|
|
});
|
|
});
|
|
});
|