mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(assistant): booking and stay cards, and a read of the bookings with their details
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* The assistant's booking reads over a real MCP session against the booted
|
||||
* container: list_reservations answers the bookings with their readable
|
||||
* details and no raw metadata, list_accommodations the stays with their place,
|
||||
* and neither answers for a trip the caller cannot see.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { makeDayAccommodation } from '../helpers/factories/itinerary';
|
||||
import { makePlace } from '../helpers/factories/places';
|
||||
import { makeReservation, makeReservationEndpoint } from '../helpers/factories/reservations';
|
||||
import { makeDay, makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
let app: INestApplication;
|
||||
let orm: MikroORM;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp();
|
||||
orm = app.get(MikroORM);
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close();
|
||||
});
|
||||
|
||||
/** One tool call as `userId` over an MCP session of its own, with every scope; the JSON it answered or its error. */
|
||||
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
|
||||
const server = createLocalMcpServer('TREK booking reads e2e');
|
||||
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
|
||||
await withRequestContext(orm, () =>
|
||||
app
|
||||
.get(McpRegistryService)
|
||||
.attach(
|
||||
server,
|
||||
{ userId, scopes: null, isStaticToken: false },
|
||||
{ include: (kind, tool) => kind === 'tool' && tool === name, around },
|
||||
),
|
||||
);
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const client = new Client({ name: 'booking-reads-e2e', version: '1.0.0' });
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
try {
|
||||
const result = await client.callTool({ name, arguments: args });
|
||||
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
|
||||
const text = content[0]?.text ?? '';
|
||||
return result.isError === true ? { error: text } : JSON.parse(text);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
describe('the booking reads an assistant makes over MCP', () => {
|
||||
it('RESREAD-E2E-001: answers the bookings with their details and the stays with their place', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id, { title: 'Berlin' });
|
||||
const day1 = await makeDay(orm, trip.id, { day_number: 1, date: '2026-06-03' });
|
||||
const day2 = await makeDay(orm, trip.id, { day_number: 2, date: '2026-06-04' });
|
||||
const flight = await makeReservation(orm, trip.id, {
|
||||
title: 'Frankfurt to Berlin',
|
||||
type: 'flight',
|
||||
day: day1.id,
|
||||
confirmation_number: 'ABC123',
|
||||
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 170', airtrail_ids: [1] }),
|
||||
});
|
||||
await makeReservationEndpoint(orm, flight.id, { role: 'from', sequence: 0, name: 'Frankfurt', code: 'FRA' });
|
||||
await makeReservationEndpoint(orm, flight.id, { role: 'to', sequence: 1, name: 'Berlin', code: 'BER' });
|
||||
const hotel = await makePlace(orm, trip.id, { name: 'Mercure', lat: 52.53, lng: 13.38 });
|
||||
await makeDayAccommodation(orm, trip.id, hotel.id, day1.id, day2.id, { check_in: '15:00', check_out: '11:00' });
|
||||
|
||||
const read = await callTool(user.id, 'list_reservations', { tripId: trip.id, kinds: ['transport'] });
|
||||
expect(read.reservations).toHaveLength(1);
|
||||
expect(read.reservations[0]).toMatchObject({
|
||||
id: flight.id,
|
||||
title: 'Frankfurt to Berlin',
|
||||
details: { airline: 'Lufthansa', flight_number: 'LH 170' },
|
||||
endpoints: [expect.objectContaining({ code: 'FRA' }), expect.objectContaining({ code: 'BER' })],
|
||||
});
|
||||
expect(read.reservations[0]).not.toHaveProperty('metadata');
|
||||
expect(read.reservations[0].details).not.toHaveProperty('airtrail_ids');
|
||||
|
||||
const stays = await callTool(user.id, 'list_accommodations', { tripId: trip.id });
|
||||
expect(stays.accommodations).toEqual([
|
||||
expect.objectContaining({
|
||||
place_id: hotel.id,
|
||||
place_name: 'Mercure',
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
check_in: '15:00',
|
||||
check_out: '11:00',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('RESREAD-E2E-002: answers neither read for a trip the caller is not on', async () => {
|
||||
const { user } = await makeUser(orm);
|
||||
const { user: stranger } = await makeUser(orm);
|
||||
const trip = await makeTrip(orm, user.id);
|
||||
expect(await callTool(stranger.id, 'list_reservations', { tripId: trip.id })).toHaveProperty('error');
|
||||
expect(await callTool(stranger.id, 'list_accommodations', { tripId: trip.id })).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
@@ -1155,3 +1155,179 @@ describe('Tool: list_upcoming_reservations', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// list_reservations / list_accommodations
|
||||
//
|
||||
// The assistant's booking reads: the bookings with their readable details,
|
||||
// never the raw metadata (provider ids, a leg's own reference), and the stays
|
||||
// with their place, each behind the trip's access check and its scope.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function withScopedHarness(userId: number, fn: (h: McpHarness) => Promise<void>, scopes: string[] | null = null) {
|
||||
const h = await createMcpHarness({ realtime, userId, withResources: false, scopes });
|
||||
try {
|
||||
await fn(h);
|
||||
} finally {
|
||||
await h.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
interface ListedBooking {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
metadata?: unknown;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function listed(userId: number, args: Record<string, unknown>): Promise<ListedBooking[]> {
|
||||
let out: ListedBooking[] = [];
|
||||
await withScopedHarness(userId, async (h) => {
|
||||
const data = parseToolResult(await h.client.callTool({ name: 'list_reservations', arguments: args })) as {
|
||||
reservations: ListedBooking[];
|
||||
};
|
||||
out = data.reservations;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A trip with a flight with stopover legs on day 1, a dinner on day 2 and a hotel stay. */
|
||||
async function seedTrip(userId: number) {
|
||||
const trip = createTrip(testDb, userId);
|
||||
const day1 = createDay(testDb, trip.id, { date: '2026-06-03' });
|
||||
const day2 = createDay(testDb, trip.id, { date: '2026-06-04' });
|
||||
const flight = await insertRow(orm, Reservations, {
|
||||
trip: trip.id,
|
||||
day: day1.id,
|
||||
title: 'Frankfurt to New York',
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
confirmation_number: 'ABC123',
|
||||
metadata: JSON.stringify({
|
||||
airline: 'Lufthansa',
|
||||
flight_number: 'LH 400',
|
||||
seat: '12A',
|
||||
airtrail_ids: [4, 5],
|
||||
legs: [
|
||||
{ from: 'FRA', to: 'MUC', flight_number: 'LH 101', confirmation_number: 'LEG-REF', dep_time: '08:15' },
|
||||
{ from: 'MUC', to: 'JFK', flight_number: 'LH 410', arr_day_id: day2.id, arr_time: '13:05' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const dinner = await insertRow(orm, Reservations, {
|
||||
trip: trip.id,
|
||||
day: day2.id,
|
||||
title: 'Dinner',
|
||||
type: 'restaurant',
|
||||
metadata: JSON.stringify({ price: '80' }),
|
||||
});
|
||||
return { trip, day1, day2, flight, dinner };
|
||||
}
|
||||
|
||||
describe('Tool: list_reservations', () => {
|
||||
it('RESREAD-MCP-001: lists the bookings with their readable details and without the raw metadata', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip } = await seedTrip(user.id);
|
||||
const bookings = await listed(user.id, { tripId: trip.id });
|
||||
expect(bookings.map((b) => b.title).sort()).toEqual(['Dinner', 'Frankfurt to New York']);
|
||||
const flight = bookings.find((b) => b.type === 'flight');
|
||||
expect(flight).not.toHaveProperty('metadata');
|
||||
expect(flight?.details).toMatchObject({ airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A' });
|
||||
expect(flight?.details.legs).toEqual([
|
||||
{ from: 'FRA', to: 'MUC', flight_number: 'LH 101', dep_time: '08:15' },
|
||||
expect.objectContaining({ from: 'MUC', to: 'JFK', flight_number: 'LH 410', arr_time: '13:05' }),
|
||||
]);
|
||||
// A provider id and a leg's own reference stay with the booking.
|
||||
expect(JSON.stringify(flight?.details)).not.toContain('airtrail_ids');
|
||||
expect(JSON.stringify(flight?.details)).not.toContain('LEG-REF');
|
||||
expect(bookings.find((b) => b.type === 'restaurant')?.details).toEqual({ price: '80' });
|
||||
});
|
||||
|
||||
it('RESREAD-MCP-005: says that a leg holds a reference, never the reference itself', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip } = await seedTrip(user.id);
|
||||
const bookings = await listed(user.id, { tripId: trip.id });
|
||||
expect(bookings.find((b) => b.type === 'flight')?.details.has_leg_reference).toBe(true);
|
||||
expect(bookings.find((b) => b.type === 'restaurant')?.details).not.toHaveProperty('has_leg_reference');
|
||||
});
|
||||
|
||||
it('RESREAD-MCP-002: keeps one kind, or the bookings on one day (a leg arriving on it counts)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day1, day2 } = await seedTrip(user.id);
|
||||
expect((await listed(user.id, { tripId: trip.id, kinds: ['transport'] })).map((b) => b.type)).toEqual(['flight']);
|
||||
expect((await listed(user.id, { tripId: trip.id, kinds: ['booking'] })).map((b) => b.type)).toEqual(['restaurant']);
|
||||
expect((await listed(user.id, { tripId: trip.id, dayId: day1.id })).map((b) => b.type)).toEqual(['flight']);
|
||||
expect((await listed(user.id, { tripId: trip.id, dayId: day2.id })).map((b) => b.type).sort()).toEqual([
|
||||
'flight',
|
||||
'restaurant',
|
||||
]);
|
||||
});
|
||||
|
||||
it('RESREAD-MCP-003: refuses a trip the user cannot see', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const { trip } = await seedTrip(user.id);
|
||||
await withScopedHarness(stranger.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_reservations', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('RESREAD-MCP-004: rides reservations:read and is hidden from a token without it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withScopedHarness(
|
||||
user.id,
|
||||
async (h) => {
|
||||
expect((await h.client.listTools()).tools.map((t) => t.name)).toContain('list_reservations');
|
||||
},
|
||||
['reservations:read'],
|
||||
);
|
||||
await withScopedHarness(
|
||||
user.id,
|
||||
async (h) => {
|
||||
expect((await h.client.listTools()).tools.map((t) => t.name)).not.toContain('list_reservations');
|
||||
},
|
||||
['places:read'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool: list_accommodations', () => {
|
||||
it('RESREAD-MCP-010: lists the stays with their place and check-in and check-out', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day1, day2 } = await seedTrip(user.id);
|
||||
const hotel = createPlace(testDb, trip.id, { name: 'Mercure', lat: 52.53, lng: 13.38 });
|
||||
createDayAccommodation(testDb, trip.id, hotel.id, day1.id, day2.id, { check_in: '15:00', confirmation: 'H-1' });
|
||||
await withScopedHarness(user.id, async (h) => {
|
||||
const data = parseToolResult(
|
||||
await h.client.callTool({ name: 'list_accommodations', arguments: { tripId: trip.id } }),
|
||||
) as { accommodations: Array<Record<string, unknown>> };
|
||||
expect(data.accommodations).toHaveLength(1);
|
||||
expect(data.accommodations[0]).toMatchObject({
|
||||
place_id: hotel.id,
|
||||
place_name: 'Mercure',
|
||||
start_day_id: day1.id,
|
||||
end_day_id: day2.id,
|
||||
check_in: '15:00',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('RESREAD-MCP-011: refuses a trip the user cannot see, and is hidden from a token without trip reads', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: stranger } = createUser(testDb);
|
||||
const { trip } = await seedTrip(user.id);
|
||||
await withScopedHarness(stranger.id, async (h) => {
|
||||
const result = await h.client.callTool({ name: 'list_accommodations', arguments: { tripId: trip.id } });
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
await withScopedHarness(
|
||||
user.id,
|
||||
async (h) => {
|
||||
expect((await h.client.listTools()).tools.map((t) => t.name)).not.toContain('list_accommodations');
|
||||
},
|
||||
['places:read'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
/**
|
||||
* The booking cards: a ticket, a stay or a venue from every booking read and
|
||||
* write, as the planner shows them. The reference never travels, only that
|
||||
* one exists; times cross zones only where both ends know theirs.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import { unchangedSinceApplied } from '../../../../../../src/nest/assistant/assistant-undo.helpers';
|
||||
import type { CardAirport, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import {
|
||||
answerCardOf,
|
||||
appliedCardOf,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
CARD_PROMPT_LINES,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { appliedChange, describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { MAX_CARD_CHARS, trekCardSchema, type BookingCard, type BookingItem, type TrekCard } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const FLIGHT = {
|
||||
id: 501,
|
||||
trip_id: 1,
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
title: 'Frankfurt to New York',
|
||||
day_id: 31,
|
||||
end_day_id: 31,
|
||||
reservation_time: '2026-06-03T08:15',
|
||||
reservation_end_time: '2026-06-03T11:05',
|
||||
confirmation_number: 'ABC123',
|
||||
needs_review: 0,
|
||||
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 400', seat: '12A', price: '420' }),
|
||||
endpoints: [
|
||||
{
|
||||
role: 'from',
|
||||
sequence: 0,
|
||||
name: 'Frankfurt',
|
||||
code: 'fra',
|
||||
lat: 50.03,
|
||||
lng: 8.57,
|
||||
timezone: 'Europe/Berlin',
|
||||
local_time: '08:15',
|
||||
local_date: '2026-06-03',
|
||||
},
|
||||
{
|
||||
role: 'to',
|
||||
sequence: 1,
|
||||
name: 'New York',
|
||||
code: 'JFK',
|
||||
lat: 40.64,
|
||||
lng: -73.78,
|
||||
timezone: 'America/New_York',
|
||||
local_time: '11:05',
|
||||
local_date: '2026-06-03',
|
||||
},
|
||||
],
|
||||
travelers: [{ user_id: 7, username: 'mia', avatar: 'mia.png', avatar_url: 'https://cdn.example/mia.png' }],
|
||||
};
|
||||
|
||||
const HOTEL = {
|
||||
id: 502,
|
||||
trip_id: 1,
|
||||
type: 'hotel',
|
||||
status: 'pending',
|
||||
title: 'Mercure stay',
|
||||
accommodation_id: '601.0',
|
||||
accommodation_place_id: 12,
|
||||
accommodation_start_day_id: 31,
|
||||
accommodation_end_day_id: 33,
|
||||
needs_review: 1,
|
||||
metadata: null,
|
||||
endpoints: [],
|
||||
travelers: [],
|
||||
};
|
||||
|
||||
const DINNER = {
|
||||
id: 503,
|
||||
trip_id: 1,
|
||||
type: 'restaurant',
|
||||
status: 'pending',
|
||||
title: 'Dinner at Borchardt',
|
||||
day_id: 32,
|
||||
reservation_time: '2026-06-04T19:00',
|
||||
reservation_end_time: '2026-06-04T21:30',
|
||||
assignment_id: 301,
|
||||
location: null,
|
||||
metadata: '{"price":"0"}',
|
||||
endpoints: [],
|
||||
travelers: [],
|
||||
};
|
||||
|
||||
const STAY = {
|
||||
id: 601,
|
||||
trip_id: 1,
|
||||
place_id: 12,
|
||||
start_day_id: 31,
|
||||
end_day_id: 33,
|
||||
check_in: '15:00',
|
||||
check_in_end: '20:00',
|
||||
check_out: '11:00',
|
||||
confirmation: 'HOTEL-XYZ',
|
||||
place_name: 'Mercure Hotel Berlin City',
|
||||
place_address: 'Invalidenstraße 38',
|
||||
place_image: '/uploads/places/mercure.jpg',
|
||||
place_lat: 52.53,
|
||||
place_lng: 13.38,
|
||||
reservation_title: 'Mercure stay',
|
||||
};
|
||||
|
||||
const SUMMARY = {
|
||||
trip: { title: 'Berlin', currency: 'EUR' },
|
||||
members: {
|
||||
owner: { id: 7, username: 'mia', avatar_url: '/uploads/avatars/mia.png' },
|
||||
collaborators: [{ id: 8, username: 'jon', avatar_url: null }],
|
||||
},
|
||||
days: [
|
||||
{ id: 31, day_number: 1, date: '2026-06-03', assignments: [], notes: [] },
|
||||
{
|
||||
id: 32,
|
||||
day_number: 2,
|
||||
date: '2026-06-04',
|
||||
assignments: [{ id: 301, place: { id: 13, name: 'Borchardt', lat: 52.51, lng: 13.39 } }],
|
||||
notes: [],
|
||||
},
|
||||
{ id: 33, day_number: 3, date: '2026-06-05', assignments: [], notes: [] },
|
||||
],
|
||||
reservations: [FLIGHT, HOTEL, DINNER],
|
||||
accommodations: [STAY],
|
||||
};
|
||||
|
||||
const PLACES = {
|
||||
places: [
|
||||
{
|
||||
id: 12,
|
||||
name: 'Mercure Hotel Berlin City',
|
||||
address: 'Invalidenstraße 38',
|
||||
lat: 52.53,
|
||||
lng: 13.38,
|
||||
google_place_id: 'ChIJmercure',
|
||||
image_url: 'https://maps.example/photo.jpg',
|
||||
},
|
||||
{ id: 13, name: 'Borchardt', lat: 52.51, lng: 13.39 },
|
||||
],
|
||||
};
|
||||
|
||||
const card = buildTripCard(1, 7, SUMMARY, PLACES, null);
|
||||
|
||||
const AIRPORTS: Record<string, CardAirport> = {
|
||||
MUC: { city: 'Munich', lat: 48.35, lng: 11.79, tz: 'Europe/Berlin' },
|
||||
LIS: { city: 'Lisbon', lat: 38.77, lng: -9.13, tz: 'Europe/Lisbon' },
|
||||
};
|
||||
|
||||
const ctx: PreviewContext = {
|
||||
card,
|
||||
categories: new Map([
|
||||
[2, 'Food'],
|
||||
[5, 'Hotel'],
|
||||
]),
|
||||
locale: 'en-GB',
|
||||
today: '2026-05-01',
|
||||
airport: (code) => AIRPORTS[code] ?? null,
|
||||
};
|
||||
|
||||
/** The card checked against the union, as the registry checks it before it streams. */
|
||||
function parsed(value: TrekCard | null | undefined): BookingCard {
|
||||
const result = trekCardSchema.safeParse(value);
|
||||
expect(result.success).toBe(true);
|
||||
expect(value?.kind).toBe('booking');
|
||||
return value as BookingCard;
|
||||
}
|
||||
|
||||
function answer(tool: string, value: unknown): BookingCard {
|
||||
return parsed(answerCardOf(tool, { toolCallId: 'tc-r', args: {}, value, card, today: '2026-05-01' }));
|
||||
}
|
||||
|
||||
function changeCard(tool: string, args: Record<string, unknown>, context: PreviewContext = ctx) {
|
||||
const preview = describeToolCall(tool, 'tc-w', args, context);
|
||||
return { preview, card: preview.card ?? null };
|
||||
}
|
||||
|
||||
function first(value: BookingCard): BookingItem {
|
||||
return value.items[0] as BookingItem;
|
||||
}
|
||||
|
||||
/** Nothing secret or remote anywhere in a card's JSON. */
|
||||
function expectClean(value: unknown): void {
|
||||
const json = JSON.stringify(value);
|
||||
for (const secret of ['ABC123', 'HOTEL-XYZ', 'LEG-REF', 'FILE-REF', 'http', '/uploads']) {
|
||||
expect(json).not.toContain(secret);
|
||||
}
|
||||
}
|
||||
|
||||
describe('booking answer cards', () => {
|
||||
it('ASTBOOK-001: a flight is a ticket with codes, times, carrier, seat, price and 8 h 50 across zones', () => {
|
||||
const list = answer('list_reservations', { reservations: [FLIGHT, HOTEL, DINNER] });
|
||||
expect(list).toMatchObject({ kind: 'booking', change: 'none', more: 0 });
|
||||
expect(list.items).toHaveLength(3);
|
||||
const flight = first(list);
|
||||
expect(flight).toMatchObject({
|
||||
reservationId: 501,
|
||||
type: 'flight',
|
||||
status: 'confirmed',
|
||||
hasReference: true,
|
||||
price: { amount: 420, currency: 'EUR' },
|
||||
travelers: [{ id: 7, name: 'mia', avatarKey: 'mia.png' }],
|
||||
});
|
||||
expect(flight.body).toMatchObject({
|
||||
shape: 'ticket',
|
||||
stops: [
|
||||
{ name: 'Frankfurt', code: 'FRA' },
|
||||
{ name: 'New York', code: 'JFK' },
|
||||
],
|
||||
legs: [
|
||||
{
|
||||
carrier: 'Lufthansa',
|
||||
number: 'LH 400',
|
||||
departs: '08:15',
|
||||
arrives: '11:05',
|
||||
departsDay: { id: 31, number: 1, date: '2026-06-03' },
|
||||
minutes: 530,
|
||||
seat: '12A',
|
||||
},
|
||||
],
|
||||
minutes: 530,
|
||||
mappable: true,
|
||||
});
|
||||
expectClean(list);
|
||||
});
|
||||
|
||||
it('ASTBOOK-002: a hotel is a stay with its nights, check-in window, check-out and the place photo key', () => {
|
||||
const stay = answer('list_reservations', { reservations: [HOTEL] });
|
||||
expect(first(stay)).toMatchObject({ type: 'hotel', status: 'pending', needsReview: true, hasReference: true });
|
||||
expect(first(stay).body).toEqual({
|
||||
shape: 'stay',
|
||||
accommodationId: 601,
|
||||
placeId: 12,
|
||||
address: 'Invalidenstraße 38',
|
||||
lat: 52.53,
|
||||
lng: 13.38,
|
||||
photoKey: 'ChIJmercure',
|
||||
checkIn: { day: { id: 31, number: 1, date: '2026-06-03' }, from: '15:00', until: '20:00' },
|
||||
checkOut: { day: { id: 33, number: 3, date: '2026-06-05' }, by: '11:00' },
|
||||
nights: 2,
|
||||
});
|
||||
const listed = answer('list_accommodations', { accommodations: [STAY, { ...STAY, reservation_title: 'Twice' }] });
|
||||
expect(listed.items).toHaveLength(1);
|
||||
expect(first(listed)).toMatchObject({ title: 'Mercure stay', reservationId: 502, status: 'pending' });
|
||||
expectClean([stay, listed]);
|
||||
});
|
||||
|
||||
it('ASTBOOK-003: a table is a venue on its day with its time range and the planned place', () => {
|
||||
const dinner = answer('list_reservations', { reservations: [DINNER] });
|
||||
expect(first(dinner)).toMatchObject({ price: null, hasReference: false, needsReview: false });
|
||||
expect(first(dinner).body).toEqual({
|
||||
shape: 'venue',
|
||||
day: { id: 32, number: 2, date: '2026-06-04' },
|
||||
starts: '19:00',
|
||||
ends: '21:30',
|
||||
location: 'Borchardt',
|
||||
placeId: 13,
|
||||
lat: 52.51,
|
||||
lng: 13.39,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTBOOK-004: without zones there is no duration; legs line up with the stops or fall back to one', () => {
|
||||
const unplaced = (name: string, code: string, sequence: number, time: string) => ({
|
||||
role: sequence === 0 ? 'from' : 'to',
|
||||
sequence,
|
||||
name,
|
||||
code,
|
||||
local_time: time,
|
||||
local_date: '2026-06-03',
|
||||
});
|
||||
const bare = {
|
||||
...FLIGHT,
|
||||
endpoints: [unplaced('Frankfurt', 'FRA', 0, '08:15'), unplaced('New York', 'JFK', 1, '11:05')],
|
||||
};
|
||||
const noZone = first(answer('list_reservations', { reservations: [bare] }));
|
||||
expect(noZone.body).toMatchObject({ minutes: null, mappable: false, legs: [{ minutes: null }] });
|
||||
const stop = { role: 'stop', sequence: 1, name: 'Munich', code: 'MUC', lat: 48.35, lng: 11.79 };
|
||||
const via = [FLIGHT.endpoints[0], stop, { ...FLIGHT.endpoints[1], sequence: 2 }];
|
||||
const legs = [
|
||||
{ flight_number: 'LH 101', dep_day_id: 31, dep_time: '08:15', arr_day_id: 31, arr_time: '09:10' },
|
||||
{ flight_number: 'LH 410', dep_day_id: 31, dep_time: '10:30', arr_day_id: 31, arr_time: '13:05' },
|
||||
];
|
||||
const twoLegs = { ...FLIGHT, endpoints: via, details: { legs } };
|
||||
const multi = first(answer('list_reservations', { reservations: [twoLegs] })).body;
|
||||
expect(multi).toMatchObject({
|
||||
legs: [
|
||||
{ number: 'LH 101', departs: '08:15', arrives: '09:10', minutes: 55 },
|
||||
{ number: 'LH 410', departs: '10:30', arrives: '13:05' },
|
||||
],
|
||||
});
|
||||
const mismatch = { ...twoLegs, details: { legs: legs.slice(0, 1) } };
|
||||
expect(first(answer('list_reservations', { reservations: [mismatch] })).body).toMatchObject({
|
||||
legs: [{ departs: '08:15', arrives: '11:05' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTBOOK-005: a train keeps number, platform and seat; a hire car without a route is one leg', () => {
|
||||
const train = {
|
||||
...FLIGHT,
|
||||
id: 504,
|
||||
type: 'train',
|
||||
endpoints: [],
|
||||
details: { train_number: 'ICE 597', platform: '7', seat: '41' },
|
||||
};
|
||||
expect(first(answer('list_reservations', { reservations: [train] })).body).toMatchObject({
|
||||
stops: [],
|
||||
legs: [{ number: 'ICE 597', platform: '7', seat: '41', departs: '08:15', arrives: '11:05' }],
|
||||
mappable: false,
|
||||
});
|
||||
const car = { id: 505, trip_id: 1, type: 'car', title: 'Hire car', reservation_time: '09:00', endpoints: [] };
|
||||
expect(first(answer('list_reservations', { reservations: [car] })).body).toMatchObject({
|
||||
legs: [{ departs: '09:00', arrives: null, departsDay: null, carrier: null }],
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTBOOK-006: a price reads only when it is a positive amount; an odd currency takes the trip one', () => {
|
||||
const priced = (metadata: unknown) =>
|
||||
first(answer('list_reservations', { reservations: [{ ...DINNER, metadata: JSON.stringify(metadata) }] })).price;
|
||||
expect(priced({ price: 'abc' })).toBeNull();
|
||||
expect(priced({ price: '0' })).toBeNull();
|
||||
expect(priced({ price: 12.5, priceCurrency: 'usd' })).toEqual({ amount: 12.5, currency: 'EUR' });
|
||||
expect(priced({ price: '30', priceCurrency: 'CHF' })).toEqual({ amount: 30, currency: 'CHF' });
|
||||
});
|
||||
|
||||
it('ASTBOOK-007: an upcoming row of another trip names that trip; a check-in is a stay; no picture travels', () => {
|
||||
const upcoming = answer('list_upcoming_reservations', {
|
||||
reservations: [
|
||||
{
|
||||
id: 900,
|
||||
trip_id: 2,
|
||||
title: 'Night train',
|
||||
type: 'train',
|
||||
status: 'confirmed',
|
||||
reservation_time: '2026-07-01T22:10',
|
||||
confirmation_number: 'ABC123',
|
||||
trip_title: 'Vienna',
|
||||
trip_cover: '/uploads/covers/vienna.jpg',
|
||||
day_date: '2026-07-01',
|
||||
place_name: null,
|
||||
place_image: 'https://maps.example/x.jpg',
|
||||
},
|
||||
{
|
||||
id: 601,
|
||||
trip_id: 1,
|
||||
title: 'Mercure stay',
|
||||
type: 'checkin',
|
||||
status: null,
|
||||
reservation_time: '15:00',
|
||||
trip_title: 'Berlin',
|
||||
day_date: '2026-06-03',
|
||||
place_name: 'Mercure Hotel Berlin City',
|
||||
},
|
||||
],
|
||||
});
|
||||
const [train, checkIn] = upcoming.items as [BookingItem, BookingItem];
|
||||
expect(train).toMatchObject({ tripId: 2, tripTitle: 'Vienna', reservationId: 900, hasReference: true });
|
||||
expect(train.body).toMatchObject({ legs: [{ departs: '22:10', departsDay: { id: null, date: '2026-07-01' } }] });
|
||||
expect(checkIn).toMatchObject({ tripId: 1, tripTitle: null, type: 'hotel', status: null, reservationId: null });
|
||||
expect(checkIn.body).toMatchObject({
|
||||
shape: 'stay',
|
||||
accommodationId: 601,
|
||||
checkIn: { day: { id: 31, date: '2026-06-03' }, from: '15:00' },
|
||||
checkOut: null,
|
||||
});
|
||||
expectClean(upcoming);
|
||||
});
|
||||
|
||||
it('ASTBOOK-008: ten bookings show eight and count the rest, well under the card size cap', () => {
|
||||
const many = Array.from({ length: 10 }, (_, i) => ({ ...FLIGHT, id: 700 + i }));
|
||||
const list = answer('list_reservations', { reservations: many });
|
||||
expect(list.items).toHaveLength(8);
|
||||
expect(list.more).toBe(2);
|
||||
expect(JSON.stringify(list).length).toBeLessThan(MAX_CARD_CHARS);
|
||||
});
|
||||
|
||||
it('ASTBOOK-009: a reference kept only on a leg still counts, and a stay reads a review flag stored as true', () => {
|
||||
const legRef = {
|
||||
...FLIGHT,
|
||||
confirmation_number: null,
|
||||
metadata: undefined,
|
||||
details: { airline: 'Lufthansa', has_leg_reference: true },
|
||||
};
|
||||
expect(first(answer('list_reservations', { reservations: [legRef] })).hasReference).toBe(true);
|
||||
const flagged = { ...SUMMARY, reservations: [FLIGHT, { ...HOTEL, needs_review: true }, DINNER] };
|
||||
const flaggedCard = buildTripCard(1, 7, flagged, PLACES, null);
|
||||
const stays = parsed(
|
||||
answerCardOf('list_accommodations', {
|
||||
toolCallId: 'tc-s',
|
||||
args: {},
|
||||
value: { accommodations: [STAY] },
|
||||
card: flaggedCard,
|
||||
today: '2026-05-01',
|
||||
}),
|
||||
);
|
||||
expect(first(stays)).toMatchObject({ reservationId: 502, needsReview: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking change cards', () => {
|
||||
it('ASTBOOK-010: a transport places code-only stops at their airports and names its own action', () => {
|
||||
const { preview, card: drawn } = changeCard('create_transport', {
|
||||
type: 'flight',
|
||||
title: 'Munich to Lisbon',
|
||||
start_day_id: 32,
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Munich', code: 'MUC', local_time: '07:00' },
|
||||
{ role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS', local_time: '09:10' },
|
||||
],
|
||||
confirmation_number: 'ABC123',
|
||||
price: 99,
|
||||
});
|
||||
expect(preview).toMatchObject({ verbKey: 'addReservation', tool: 'create_transport', subject: 'Munich to Lisbon' });
|
||||
const item = first(parsed(drawn));
|
||||
expect(parsed(drawn).change).toBe('create');
|
||||
expect(item).toMatchObject({ status: 'pending', reservationId: null, hasReference: true, price: { amount: 99 } });
|
||||
expect(item.body).toMatchObject({
|
||||
stops: [
|
||||
{ code: 'MUC', lat: 48.35, lng: 11.79 },
|
||||
{ code: 'LIS', lat: 38.77, lng: -9.13 },
|
||||
],
|
||||
legs: [{ departs: '07:00', arrives: '09:10', departsDay: { id: 32 }, minutes: 190 }],
|
||||
mappable: false,
|
||||
});
|
||||
const unknown = changeCard('create_transport', {
|
||||
type: 'flight',
|
||||
title: 'Somewhere',
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Nowhere', code: 'ZZZ' },
|
||||
{ role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS' },
|
||||
],
|
||||
});
|
||||
expect(first(parsed(unknown.card)).body).toMatchObject({ stops: [{ lat: null, lng: null }, { lat: 38.77 }] });
|
||||
expectClean([preview, drawn]);
|
||||
});
|
||||
|
||||
it('ASTBOOK-011: a hotel booking with a place and both days draws the stay and puts it on its check-in day', () => {
|
||||
const { preview, card: drawn } = changeCard('create_reservation', {
|
||||
title: 'Mercure',
|
||||
type: 'hotel',
|
||||
place_id: 12,
|
||||
start_day_id: 31,
|
||||
end_day_id: 33,
|
||||
check_in: '15:00',
|
||||
check_out: '11:00',
|
||||
});
|
||||
expect(preview.effect).toEqual({
|
||||
kind: 'add',
|
||||
dayId: 31,
|
||||
fromDayId: null,
|
||||
placeId: 12,
|
||||
assignmentId: null,
|
||||
place: null,
|
||||
});
|
||||
expect(first(parsed(drawn)).body).toMatchObject({
|
||||
shape: 'stay',
|
||||
placeId: 12,
|
||||
photoKey: 'ChIJmercure',
|
||||
checkIn: { day: { id: 31 }, from: '15:00' },
|
||||
checkOut: { day: { id: 33 }, by: '11:00' },
|
||||
nights: 2,
|
||||
});
|
||||
const table = changeCard('create_reservation', { title: 'Table', type: 'restaurant', assignment_id: 301 });
|
||||
expect(table.preview.effect).toBeNull();
|
||||
expect(first(parsed(table.card)).body).toMatchObject({ shape: 'venue', placeId: 13, location: 'Borchardt' });
|
||||
});
|
||||
|
||||
it('ASTBOOK-012: an update marks only the fields whose card value changes; an unknown booking draws none', () => {
|
||||
const { preview, card: drawn } = changeCard('update_reservation', { reservationId: 503, status: 'confirmed' });
|
||||
expect(preview).toMatchObject({ verbKey: 'updateReservation', tool: 'update_reservation' });
|
||||
expect(preview.rows).toEqual([
|
||||
{ op: 'change', labelKey: 'field', field: 'status', before: 'pending', after: 'confirmed' },
|
||||
]);
|
||||
expect(parsed(drawn)).toMatchObject({ change: 'update' });
|
||||
expect(first(parsed(drawn))).toMatchObject({ status: 'confirmed', changed: ['status'] });
|
||||
const same = changeCard('update_reservation', { reservationId: 503, title: 'Dinner at Borchardt' });
|
||||
expect(first(parsed(same.card)).changed).toEqual([]);
|
||||
const moved = changeCard('update_transport', {
|
||||
reservationId: 501,
|
||||
endpoints: [FLIGHT.endpoints[0], { role: 'to', sequence: 1, name: 'Lisbon', code: 'LIS' }],
|
||||
});
|
||||
expect(first(parsed(moved.card)).changed).toEqual(expect.arrayContaining(['route']));
|
||||
expect(first(parsed(moved.card)).changed).not.toContain('status');
|
||||
expect(changeCard('update_reservation', { reservationId: 999, status: 'confirmed' }).card).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTBOOK-013: a removal draws the booking as it is; new travellers leave out who is not on the trip', () => {
|
||||
expect(parsed(changeCard('delete_transport', { reservationId: 501 }).card)).toMatchObject({ change: 'remove' });
|
||||
expect(parsed(changeCard('delete_accommodation', { accommodationId: 601 }).card).change).toBe('remove');
|
||||
const { preview, card: drawn } = changeCard('set_reservation_travelers', { reservationId: 503, user_ids: [8, 99] });
|
||||
expect(preview).toMatchObject({ verbKey: 'updateReservation', tool: 'set_reservation_travelers' });
|
||||
expect(first(parsed(drawn))).toMatchObject({
|
||||
travelers: [{ id: 8, name: 'jon', avatarKey: null }],
|
||||
changed: ['travelers'],
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTBOOK-014: a stay names its verb, shows its place and lands on its check-in day', () => {
|
||||
const { preview, card: drawn } = changeCard('create_accommodation', {
|
||||
place_id: 12,
|
||||
start_day_id: 31,
|
||||
end_day_id: 32,
|
||||
check_in: '14:00',
|
||||
confirmation: 'HOTEL-XYZ',
|
||||
});
|
||||
expect(preview).toMatchObject({ verbKey: 'addStay', tool: 'create_accommodation', undoable: true });
|
||||
expect(preview.effect).toMatchObject({ kind: 'add', dayId: 31, placeId: 12 });
|
||||
expect(first(parsed(drawn))).toMatchObject({
|
||||
title: 'Mercure Hotel Berlin City',
|
||||
status: null,
|
||||
hasReference: true,
|
||||
});
|
||||
expect(changeCard('create_accommodation', { place_id: 404, start_day_id: 31, end_day_id: 32 }).card).toBeNull();
|
||||
const added = changeCard('create_place_accommodation', {
|
||||
name: 'Hotel Adlon',
|
||||
address: 'Unter den Linden 77',
|
||||
lat: 52.516,
|
||||
lng: 13.38,
|
||||
google_place_id: 'ChIJadlon',
|
||||
start_day_id: 32,
|
||||
end_day_id: 33,
|
||||
price: 840,
|
||||
currency: 'eur',
|
||||
});
|
||||
expect(added.preview.verbKey).toBe('addStay');
|
||||
expect(added.preview.effect).toMatchObject({
|
||||
kind: 'add',
|
||||
dayId: 32,
|
||||
place: { name: 'Hotel Adlon', lat: 52.516, lng: 13.38, categoryId: 5 },
|
||||
});
|
||||
expect(first(parsed(added.card))).toMatchObject({ price: { amount: 840, currency: 'EUR' } });
|
||||
expect(first(parsed(added.card)).body).toMatchObject({ placeId: null, photoKey: 'ChIJadlon', nights: 1 });
|
||||
// Without a position there is no pin, so no stop is previewed on the day.
|
||||
const unplaced = changeCard('create_place_accommodation', { name: 'Somewhere', start_day_id: 32, end_day_id: 33 });
|
||||
expect(unplaced.preview.effect).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTBOOK-015: moving a stay to another check-in day moves its stop; linking a hotel marks the stay', () => {
|
||||
const { preview, card: drawn } = changeCard('update_accommodation', { accommodationId: 601, start_day_id: 32 });
|
||||
expect(preview).toMatchObject({ verbKey: 'updateStay', subject: 'Mercure Hotel Berlin City' });
|
||||
expect(preview.effect).toMatchObject({ kind: 'move', dayId: 32, fromDayId: 31, placeId: 12 });
|
||||
expect(first(parsed(drawn)).changed).toContain('stay');
|
||||
const linked = changeCard('link_hotel_accommodation', {
|
||||
reservationId: 502,
|
||||
place_id: 12,
|
||||
start_day_id: 31,
|
||||
end_day_id: 32,
|
||||
check_in: '16:00',
|
||||
});
|
||||
expect(linked.preview).toMatchObject({ verbKey: 'updateStay', tool: 'link_hotel_accommodation' });
|
||||
expect(first(parsed(linked.card)).changed).toEqual(expect.arrayContaining(['stay', 'checkIn']));
|
||||
});
|
||||
|
||||
it('ASTBOOK-016: a booking read from a file is drawn with its stay nights by date and no reference', () => {
|
||||
const { preview, card: drawn } = changeCard('import_booking', {
|
||||
type: 'hotel',
|
||||
title: 'Mercure Hotel Berlin City',
|
||||
confirmation_number: 'FILE-REF',
|
||||
_accommodation: { check_in: '2026-06-03T15:00', check_out: '2026-06-05T11:00', confirmation: 'FILE-REF' },
|
||||
_venue: { name: 'Mercure', address: 'Invalidenstraße 38', lat: 52.53, lng: 13.38 },
|
||||
source: { fileName: 'mercure.pdf', index: 0 },
|
||||
});
|
||||
expect(preview.verbKey).toBe('importBooking');
|
||||
expect(first(parsed(drawn))).toMatchObject({ hasReference: true, status: null });
|
||||
expect(first(parsed(drawn)).body).toMatchObject({
|
||||
checkIn: { day: { id: 31 }, from: '15:00' },
|
||||
checkOut: { day: { id: 33 }, by: '11:00' },
|
||||
nights: 2,
|
||||
lat: 52.53,
|
||||
});
|
||||
expect(JSON.stringify(drawn)).not.toContain('FILE-REF');
|
||||
// Applied, the import answers the id alone: the card is the preview's, saved under that id.
|
||||
const slim = { reservation: { id: 502, title: 'Mercure Hotel Berlin City', type: 'hotel', day_id: null } };
|
||||
const saved = parsed(appliedCardOf('import_booking', { args: {}, result: slim, preview }));
|
||||
expect(saved.change).toBe('none');
|
||||
expect(first(saved)).toMatchObject({ reservationId: 502, hasReference: true, changed: [] });
|
||||
expect(first(saved).body).toMatchObject({
|
||||
checkIn: { day: { id: 31 }, from: '15:00' },
|
||||
checkOut: { day: { id: 33 }, by: '11:00' },
|
||||
nights: 2,
|
||||
});
|
||||
expect(appliedCardOf('import_booking', { args: {}, result: 'Not added.', preview })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTBOOK-018: an applied flight from a file keeps its route, times and price, and can show it on the map', () => {
|
||||
const args = {
|
||||
type: 'flight',
|
||||
title: 'Frankfurt to New York',
|
||||
reservation_time: '2026-06-03T08:15',
|
||||
reservation_end_time: '2026-06-03T11:05',
|
||||
confirmation_number: 'FILE-REF',
|
||||
metadata: { airline: 'Lufthansa', flight_number: 'LH 400', price: '420' },
|
||||
endpoints: FLIGHT.endpoints,
|
||||
source: { fileName: 'lh400.pdf', index: 0 },
|
||||
};
|
||||
const { preview, card: drawn } = changeCard('import_booking', args);
|
||||
expect(first(parsed(drawn)).body).toMatchObject({ mappable: false });
|
||||
const slim = { reservation: { id: 520, title: args.title, type: 'flight', day_id: 31 } };
|
||||
const saved = first(parsed(appliedCardOf('import_booking', { args: {}, result: slim, preview })));
|
||||
expect(saved).toMatchObject({ reservationId: 520, hasReference: true, price: { amount: 420, currency: 'EUR' } });
|
||||
expect(saved.body).toMatchObject({
|
||||
stops: [{ code: 'FRA' }, { code: 'JFK' }],
|
||||
legs: [{ departs: '08:15', arrives: '11:05', carrier: 'Lufthansa' }],
|
||||
minutes: 530,
|
||||
mappable: true,
|
||||
});
|
||||
expectClean(saved);
|
||||
});
|
||||
|
||||
it('ASTBOOK-019: a hotel changed into another type reads as that type, not as the stay it still links', () => {
|
||||
const { card: drawn } = changeCard('update_reservation', { reservationId: 502, type: 'restaurant' });
|
||||
const item = first(parsed(drawn));
|
||||
expect(item.body.shape).toBe('venue');
|
||||
expect(item.changed).toEqual(expect.arrayContaining(['type']));
|
||||
const preview = describeToolCall('update_reservation', 'tc-w', { reservationId: 502, type: 'restaurant' }, ctx);
|
||||
const result = { reservation: { ...HOTEL, type: 'restaurant' } };
|
||||
const saved = parsed(appliedCardOf('update_reservation', { args: {}, result, preview }));
|
||||
expect(first(saved).body.shape).toBe('venue');
|
||||
});
|
||||
|
||||
it('ASTBOOK-017: every curated booking write describes empty arguments with its own verb', () => {
|
||||
for (const tool of [
|
||||
'create_transport',
|
||||
'update_reservation',
|
||||
'update_transport',
|
||||
'set_reservation_travelers',
|
||||
'link_hotel_accommodation',
|
||||
'create_accommodation',
|
||||
'create_place_accommodation',
|
||||
'update_accommodation',
|
||||
]) {
|
||||
expect(changeCard(tool, {}).preview.verbKey, tool).toBe(ASSISTANT_WRITE_TOOLS[tool].verbKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applied booking cards and receipts', () => {
|
||||
const previewOf = (tool: string, args: Record<string, unknown>) => describeToolCall(tool, 'tc-w', args, ctx);
|
||||
|
||||
it('ASTBOOK-020: an applied booking is the saved row, its days named from the preview', () => {
|
||||
const args = { type: 'flight', title: 'Frankfurt to New York', start_day_id: 31, endpoints: FLIGHT.endpoints };
|
||||
const preview = previewOf('create_transport', args);
|
||||
const saved = parsed(appliedCardOf('create_transport', { args, result: { reservation: FLIGHT }, preview }));
|
||||
expect(saved.change).toBe('none');
|
||||
expect(first(saved)).toMatchObject({ reservationId: 501, status: 'confirmed', hasReference: true });
|
||||
expect(first(saved).body).toMatchObject({ mappable: true, legs: [{ departsDay: { id: 31, number: 1 } }] });
|
||||
expectClean(saved);
|
||||
expect(appliedCardOf('create_transport', { args, result: 'Transport not found.', preview })).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTBOOK-021: an applied stay keeps the times and photo the preview showed; travellers are replaced', () => {
|
||||
const args = {
|
||||
name: 'Hotel Adlon',
|
||||
google_place_id: 'ChIJadlon',
|
||||
start_day_id: 32,
|
||||
end_day_id: 33,
|
||||
check_in: '15:00',
|
||||
price: 840,
|
||||
currency: 'EUR',
|
||||
};
|
||||
const preview = previewOf('create_place_accommodation', args);
|
||||
const result = {
|
||||
place: {
|
||||
id: 77,
|
||||
name: 'Hotel Adlon',
|
||||
google_place_id: 'ChIJadlon',
|
||||
lat: 52.516,
|
||||
lng: 13.38,
|
||||
price: 840,
|
||||
currency: 'EUR',
|
||||
},
|
||||
accommodation: { id: 602, trip_id: 1, place_id: 77, start_day_id: 32, end_day_id: 33, check_in: null },
|
||||
assignment: { id: 9 },
|
||||
};
|
||||
const saved = parsed(appliedCardOf('create_place_accommodation', { args, result, preview }));
|
||||
// The price lives on the place the stay was created with.
|
||||
expect(first(saved).price).toEqual({ amount: 840, currency: 'EUR' });
|
||||
expect(first(saved).body).toMatchObject({
|
||||
accommodationId: 602,
|
||||
placeId: 77,
|
||||
photoKey: 'ChIJadlon',
|
||||
checkIn: { day: { id: 32, number: 2 }, from: '15:00' },
|
||||
});
|
||||
const travelers = previewOf('set_reservation_travelers', { reservationId: 503, user_ids: [8] });
|
||||
const set = parsed(
|
||||
appliedCardOf('set_reservation_travelers', {
|
||||
args: {},
|
||||
result: { travelers: [{ user_id: 7, username: 'mia', avatar: null }] },
|
||||
preview: travelers,
|
||||
}),
|
||||
);
|
||||
expect(first(set).travelers).toEqual([{ id: 7, name: 'mia', avatarKey: null }]);
|
||||
});
|
||||
|
||||
it('ASTBOOK-022: a stay write files its receipt under the stay, a travellers write under the booking', () => {
|
||||
const result = { accommodation: { id: 601 }, assignment: { id: 9 }, place: { id: 12 } };
|
||||
expect(appliedChange('create_place_accommodation', 'tc-1', {}, result, 31, null)).toMatchObject({
|
||||
entity: 'accommodation',
|
||||
ids: [601],
|
||||
});
|
||||
expect(appliedChange('update_accommodation', 'tc-2', { accommodationId: 601 }, result, null, null)).toMatchObject({
|
||||
entity: 'accommodation',
|
||||
ids: [601],
|
||||
});
|
||||
expect(appliedChange('set_reservation_travelers', 'tc-3', { reservationId: 503 }, {}, null, null)).toMatchObject({
|
||||
entity: 'reservation',
|
||||
ids: [503],
|
||||
});
|
||||
expect(appliedChange('create_transport', 'tc-4', {}, { reservation: FLIGHT }, null, null)).toMatchObject({
|
||||
entity: 'reservation',
|
||||
ids: [501],
|
||||
dayId: 31,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('booking undo, naming and prompt', () => {
|
||||
const snapshot = (summary: Record<string, unknown> = SUMMARY, places: unknown = PLACES) => ({ summary, places });
|
||||
|
||||
it('ASTBOOK-030: a created booking or stay is taken back by its own removal', () => {
|
||||
expect(ASSISTANT_UNDO_MAP.create_reservation.args({}, { reservation: { id: 503 } }, null)).toEqual({
|
||||
reservationId: 503,
|
||||
});
|
||||
expect(ASSISTANT_UNDO_MAP.create_transport.tool).toBe('delete_transport');
|
||||
expect(ASSISTANT_UNDO_MAP.create_accommodation.args({}, { accommodation: { id: 601 } }, null)).toEqual({
|
||||
accommodationId: 601,
|
||||
});
|
||||
expect(ASSISTANT_UNDO_MAP.create_place_accommodation.args({}, { place: { id: 12 } }, null)).toEqual({
|
||||
placeId: 12,
|
||||
});
|
||||
expect(ASSISTANT_UNDO_MAP.create_accommodation.args({}, 'Failed.', null)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTBOOK-031: an undo holds while the booking or stay is as created, and stops once it changed', () => {
|
||||
expect(unchangedSinceApplied('create_reservation', { reservation: DINNER }, snapshot())).toBe(true);
|
||||
const confirmed = { ...SUMMARY, reservations: [FLIGHT, HOTEL, { ...DINNER, status: 'confirmed' }] };
|
||||
expect(unchangedSinceApplied('create_reservation', { reservation: DINNER }, snapshot(confirmed))).toBe(false);
|
||||
expect(unchangedSinceApplied('create_accommodation', { accommodation: STAY }, snapshot())).toBe(true);
|
||||
const later = { ...SUMMARY, accommodations: [{ ...STAY, check_in: '18:00' }] };
|
||||
expect(unchangedSinceApplied('create_accommodation', { accommodation: STAY }, snapshot(later))).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTBOOK-032: a place created with its stay can go while only that stay and its booking use it', () => {
|
||||
const place = { id: 12, name: 'Mercure Hotel Berlin City', lat: 52.53, lng: 13.38 };
|
||||
const result = { place, accommodation: STAY, assignment: { id: 301 } };
|
||||
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot())).toBe(true);
|
||||
const booked = { ...SUMMARY, reservations: [...SUMMARY.reservations, { id: 510, place_id: 12, title: 'Spa' }] };
|
||||
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot(booked))).toBe(false);
|
||||
const planned = {
|
||||
...SUMMARY,
|
||||
days: [{ id: 33, day_number: 3, assignments: [{ id: 302, day_id: 33, place_id: 12 }] }],
|
||||
};
|
||||
expect(unchangedSinceApplied('create_place_accommodation', result, snapshot(planned))).toBe(false);
|
||||
});
|
||||
|
||||
it('ASTBOOK-033: a generic stay removal names the stay; the prompt tells the model to leave the cards alone', () => {
|
||||
const preview = describeToolCall('delete_accommodation', 'tc-9', { accommodationId: 601 }, ctx);
|
||||
expect(JSON.stringify(preview.rows)).toContain('Mercure Hotel Berlin City');
|
||||
expect(CARD_PROMPT_LINES.some((line) => line.includes('list_reservations'))).toBe(true);
|
||||
expect(CARD_PROMPT_LINES.some((line) => line.includes('create_place_accommodation'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,9 @@
|
||||
import { localParts, resolveTimeZone } from '../../../../src/nest/common/timezoneService';
|
||||
import {
|
||||
localParts,
|
||||
offsetMinutesAt,
|
||||
resolveTimeZone,
|
||||
wallClockInstant,
|
||||
} from '../../../../src/nest/common/timezoneService';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -14,4 +19,31 @@ describe('timezoneService', () => {
|
||||
expect(localParts('invalid', 'Asia/Tokyo')).toEqual({ date: null, time: null });
|
||||
expect(localParts(null, null)).toEqual({ date: null, time: null });
|
||||
});
|
||||
|
||||
it('TZSVC-001: reads a zone offset in minutes east of UTC, summer and winter', () => {
|
||||
expect(offsetMinutesAt('Europe/Berlin', Date.UTC(2026, 6, 1))).toBe(120);
|
||||
expect(offsetMinutesAt('Europe/Berlin', Date.UTC(2026, 0, 1))).toBe(60);
|
||||
expect(offsetMinutesAt('America/St_Johns', Date.UTC(2026, 0, 1))).toBe(-210);
|
||||
expect(offsetMinutesAt('UTC', Date.UTC(2026, 0, 1))).toBe(0);
|
||||
});
|
||||
|
||||
it('TZSVC-002: turns a wall clock in a zone into its instant', () => {
|
||||
expect(wallClockInstant('2026-06-03', '08:15', 'Europe/Berlin')).toBe(Date.UTC(2026, 5, 3, 6, 15));
|
||||
expect(wallClockInstant('2026-06-03', '11:05', 'America/New_York')).toBe(Date.UTC(2026, 5, 3, 15, 5));
|
||||
// On the day the clocks go back, a midday clock already reads winter time.
|
||||
expect(wallClockInstant('2026-10-25', '12:00', 'Europe/Berlin')).toBe(Date.UTC(2026, 9, 25, 11, 0));
|
||||
});
|
||||
|
||||
it('TZSVC-003: refuses a malformed date, a malformed time and a zone that does not exist', () => {
|
||||
expect(wallClockInstant('2026-6-3', '08:15', 'Europe/Berlin')).toBeNull();
|
||||
expect(wallClockInstant('2026-06-03', '24:00', 'Europe/Berlin')).toBeNull();
|
||||
expect(wallClockInstant('2026-06-03', '08:15', 'Mars/Olympus')).toBeNull();
|
||||
});
|
||||
|
||||
it('TZSVC-004: refuses a day the month does not have instead of rolling it over', () => {
|
||||
expect(wallClockInstant('2026-02-31', '08:15', 'Europe/Berlin')).toBeNull();
|
||||
expect(wallClockInstant('2026-04-31', '08:15', 'Europe/Berlin')).toBeNull();
|
||||
expect(wallClockInstant('2026-13-01', '08:15', 'Europe/Berlin')).toBeNull();
|
||||
expect(wallClockInstant('2028-02-29', '08:15', 'UTC')).toBe(Date.UTC(2028, 1, 29, 8, 15));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user