Files
TREK/server/tests/e2e/packing-mcp.e2e.test.ts
T
2026-10-22 15:50:57 +02:00

135 lines
6.8 KiB
TypeScript

/**
* Packing e2e for the list as an MCP client reads it: list_packing_items
* called through a real MCP session on the whole app (buildApp, the real
* registry with its addon gate, scopes and error mapper, the real
* PackingService and a migrated temp SQLite), the way the assistant's
* packing cards read it.
*/
import { buildApp } from '../../src/bootstrap';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { withRequestContext } from '../../src/nest/database/request-context';
import { addPackingBagMembers, makePackingBag, makePackingItem } from '../helpers/factories/packing';
import { setAddonEnabled } from '../helpers/factories/settings';
import { addTripMember, 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;
type Answer = {
items: Array<Record<string, unknown>>;
bags: Array<Record<string, unknown>>;
category_assignees: Record<string, unknown>;
};
/** The tools `userId` is offered with `scopes`, and one call of list_packing_items: its JSON, or its error. */
async function listItems(userId: number, args: Record<string, unknown>, scopes: string[] | null = null) {
const server = createLocalMcpServer('TREK packing e2e');
const around = (_info: unknown, call: () => unknown): unknown => withRequestContext(orm, call);
await withRequestContext(orm, () =>
app
.get(McpRegistryService)
.attach(server, { userId, scopes, isStaticToken: false }, { include: (kind) => kind === 'tool', around }),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'packing-e2e', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
const offered = (await client.listTools()).tools.map((tool) => tool.name);
if (!offered.includes('list_packing_items')) return { offered, answer: null, error: null };
const result = await client.callTool({ name: 'list_packing_items', arguments: args });
const content = Array.isArray(result.content) ? (result.content as Array<{ text?: string }>) : [];
const text = content[0]?.text ?? '';
if (result.isError === true) return { offered, answer: null, error: text };
return { offered, answer: JSON.parse(text) as Answer, error: null };
} finally {
await client.close();
await server.close();
}
}
const names = (answer: Answer | null) => (answer?.items ?? []).map((item) => item.name);
beforeAll(async () => {
app = await buildApp();
orm = app.get(MikroORM);
await setAddonEnabled(orm, 'packing', true);
}, 60_000);
afterAll(async () => {
await app?.close();
});
describe('the packing list over MCP', () => {
it('PACKMCP-E2E-001: lists the items the user may see with the bags and who looks after each category', async () => {
const { user } = await makeUser(orm);
const { user: member } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Lisbon' });
await addTripMember(orm, trip.id, member.id, user.id);
const bag = await makePackingBag(orm, trip.id, { name: 'Carry-on', color: '#6366f1' });
await addPackingBagMembers(orm, bag.id, [user.id]);
await makePackingItem(orm, trip.id, { name: 'Rain jacket', category: 'Clothes', bag: bag.id, sort_order: 1 });
await makePackingItem(orm, trip.id, { name: 'Sunscreen', category: 'Toiletries', checked: 1, sort_order: 2 });
await makePackingItem(orm, trip.id, {
name: 'Diary',
category: 'Clothes',
is_private: 1,
owner: member.id,
sort_order: 3,
});
const { answer } = await listItems(user.id, { tripId: trip.id });
// Another member's personal item stays with them (#858).
expect(names(answer)).toEqual(['Rain jacket', 'Sunscreen']);
expect(answer?.items[0]).toMatchObject({ category: 'Clothes', bag_id: bag.id, quantity: 1 });
expect(answer?.bags).toEqual([expect.objectContaining({ id: bag.id, name: 'Carry-on', color: '#6366f1' })]);
expect(answer?.category_assignees).toEqual({});
const own = await listItems(member.id, { tripId: trip.id });
expect(names(own.answer)).toContain('Diary');
});
it('PACKMCP-E2E-002: narrows to one category, one bag, the items in no bag, or what is open or packed', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
const bag = await makePackingBag(orm, trip.id, { name: 'Daypack' });
await makePackingItem(orm, trip.id, { name: 'Boots', category: 'Hiking', bag: bag.id, sort_order: 1 });
await makePackingItem(orm, trip.id, { name: 'Map', category: 'Hiking', checked: 1, sort_order: 2 });
await makePackingItem(orm, trip.id, { name: 'Charger', category: 'Tech', sort_order: 3 });
expect(names((await listItems(user.id, { tripId: trip.id, category: 'Hiking' })).answer)).toEqual(['Boots', 'Map']);
expect(names((await listItems(user.id, { tripId: trip.id, bag_id: bag.id })).answer)).toEqual(['Boots']);
expect(names((await listItems(user.id, { tripId: trip.id, bag_id: null })).answer)).toEqual(['Map', 'Charger']);
expect(names((await listItems(user.id, { tripId: trip.id, status: 'open' })).answer)).toEqual(['Boots', 'Charger']);
expect(names((await listItems(user.id, { tripId: trip.id, status: 'packed' })).answer)).toEqual(['Map']);
// The grouping only says how the list is shown: it filters nothing.
expect(names((await listItems(user.id, { tripId: trip.id, group_by: 'bag' })).answer)).toHaveLength(3);
});
it('PACKMCP-E2E-003: refuses a trip the user is not part of', async () => {
const { user } = await makeUser(orm);
const { user: stranger } = await makeUser(orm);
const trip = await makeTrip(orm, user.id);
await makePackingItem(orm, trip.id, { name: 'Passport' });
const { answer, error } = await listItems(stranger.id, { tripId: trip.id });
expect(answer).toBeNull();
expect(error).toBeTruthy();
});
it('PACKMCP-E2E-004: needs the packing read scope, and is gone while the Lists addon is off', async () => {
const { user } = await makeUser(orm);
expect((await listItems(user.id, { tripId: 1 }, ['trips:read'])).offered).not.toContain('list_packing_items');
expect((await listItems(user.id, { tripId: 1 }, ['packing:read'])).offered).toContain('list_packing_items');
await setAddonEnabled(orm, 'packing', false);
try {
expect((await listItems(user.id, { tripId: 1 })).offered).not.toContain('list_packing_items');
} finally {
await setAddonEnabled(orm, 'packing', true);
}
});
});