Files
TREK/server/tests/unit/mcp/tools-transports.test.ts
T
Maurice 0a7e460253 fix(planner): keep a parked car off the days between drop-off and pickup
A parking booking spanning the trip showed up on every day, sometimes in between
planned activities. It now appears on its first and last day only, with drop-off
and pickup wording, and contributes nothing to the days in between, not even a
badge in the day header. Applied to the day plan, the mobile timeline, the PDF
export and the shared trip view, so a shared link shows what the app shows.

Rental cars stay as they are. The report names the car's smaller tag as the thing
it does not want for parking, not as something to change, and a car's middle days
move into the day header, which is what people expect from a vehicle they are
driving around.

The distance connector between legs is keyed off the row that gets hidden, so
hiding it naively takes the "2 km" label with it, which is what #1504 was about.
There is a test that fails without that guard.

Each segment of a stopover flight or train can also carry its own booking
reference now, since airlines often issue one per flight rather than one per
booking. Legs already live as a JSON array on the reservation, so this is an
optional field per entry: no migration, no contract change, and a booking without
it renders exactly as before. Readable everywhere it can be entered, desktop and
mobile, through the same blur treatment other booking codes get, plus the PDF and
the calendar description. create_transport and update_transport preserve it too,
otherwise they would accept the field and silently drop it.

Closes #1937
Closes #1943
2026-08-17 14:48:23 +02:00

1043 lines
45 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 { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
broadcastMock.mockClear();
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ 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 = (reservationId: number) => JSON.parse(
(testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservationId) as { metadata: string }).metadata
);
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 = testDb.prepare('SELECT lat, lng FROM reservation_endpoints WHERE reservation_id = ?').all(data.reservation.id) as any[];
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);
});
});
});
// ---------------------------------------------------------------------------
// 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 = Number(testDb.prepare("INSERT INTO days (trip_id, date, day_number) VALUES (?, '2026-07-01', 1)").run(otherTrip.id).lastInsertRowid);
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 = Number(testDb.prepare("INSERT INTO days (trip_id, date, day_number) VALUES (?, '2026-07-02', 1)").run(otherTrip.id).lastInsertRowid);
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 = testDb.prepare('SELECT name, category, total_price FROM budget_items WHERE reservation_id = ?').get(data.reservation.id) as { name: string; category: string; total_price: number };
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 = testDb.prepare('SELECT category FROM budget_items WHERE reservation_id = ?').get(data.reservation.id) as { category: string };
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(testDb.prepare('SELECT id FROM budget_items WHERE reservation_id = ?').get(data.reservation.id)).toBeUndefined();
});
});
});
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 = Number(testDb.prepare("INSERT INTO days (trip_id, date, day_number) VALUES (?, '2026-08-01', 1)").run(otherTrip.id).lastInsertRowid);
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(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id)).toBeUndefined();
expect(broadcastMock.mock.calls.some(c => c[1] === 'reservation:deleted')).toBe(true);
});
});
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');
});
});
});
// ---------------------------------------------------------------------------
// 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(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 = 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 = 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 = 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 = 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 = 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.
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(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 = 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 = 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 = 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 = storedMetadata(created.reservation.id);
await h.client.callTool({
name: 'update_transport',
arguments: { tripId: trip.id, reservationId: created.reservation.id, status: 'confirmed' },
});
expect(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 = (testDb.prepare('SELECT id FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence')
.all(created.reservation.id) as any[]).map(r => r.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 = (testDb.prepare('SELECT id FROM reservation_endpoints WHERE reservation_id = ? ORDER BY sequence')
.all(created.reservation.id) as any[]).map(r => r.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);
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run('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(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);
testDb.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(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(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');
});
});
});