mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(assistant): booking and stay cards, and a read of the bookings with their details
This commit is contained in:
@@ -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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user