feat(assistant): booking and stay cards, and a read of the bookings with their details

This commit is contained in:
Maurice
2026-10-22 15:50:50 +02:00
parent b52990bf27
commit b4e188fbfc
67 changed files with 6336 additions and 103 deletions
@@ -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');
});
});