Files
TREK/server/tests/e2e/journey-book.e2e.test.ts
T

202 lines
8.2 KiB
TypeScript

/**
* Studio book e2e: the book tools called through a real MCP session on the
* whole app (buildApp, the real registry with its addon gate, scopes and error
* mapper, the real services and a migrated temp SQLite), checked against the
* REST route the Studio saves through. The broadcasts are spied on the
* container's RealtimeService.
*
* What it pins: an MCP write is what GET /api/journeys/:id/book reads next; a
* viewer's write is refused with the tool's own words, and an operation the
* engine refuses with the operation it names; a REST save and an MCP write
* send the same event to the same users, REST leaving out the tab that saved
* and MCP none; and the tools are gone while the Journey addon is off.
*/
import { ADDON_IDS } from '../../src/addons';
import { buildApp } from '../../src/bootstrap';
import { createLocalMcpServer, McpRegistryService } from '../../src/nest-mcp';
import { withRequestContext } from '../../src/nest/database/request-context';
import { authCookie } from '../helpers/auth';
import { addJourneyContributor, makeJourney } from '../helpers/factories/journeys';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeUser } from '../helpers/factories/users';
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
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 { bookWriteResultSchema } from '@trek/shared';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const BOOK_TOOLS = [
'get_book',
'describe_book_page',
'list_book_layouts',
'create_book',
'add_book_pages',
'move_book_page',
'delete_book_pages',
'edit_book_page',
'set_book_cover',
'restore_book_page',
];
let app: INestApplication;
let orm: MikroORM;
let broadcasts: RealtimeSpies;
beforeAll(async () => {
app = await buildApp();
await app.init();
orm = app.get(MikroORM);
broadcasts = spyOnRealtime(app);
}, 60_000);
beforeEach(async () => {
await setAddonEnabled(orm, ADDON_IDS.JOURNEY, true);
broadcasts.broadcastToUser.mockClear();
});
afterAll(async () => {
await app?.close();
});
/** An MCP session of its own as `userId`, with every scope, holding only the tools `include` lets through. */
async function session<T>(userId: number, include: (tool: string) => boolean, use: (client: Client) => Promise<T>) {
const server = createLocalMcpServer('TREK book 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' && include(tool), around },
),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'book-e2e', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
return await use(client);
} finally {
await client.close();
await server.close();
}
}
/** One tool call: the JSON it answered, or its error text. */
async function callTool(userId: number, name: string, args: Record<string, unknown>) {
const only = (tool: string) => tool === name;
return session(userId, only, async (client) => {
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) as unknown);
});
}
/** The user sends as [user, event type, left-out socket], in user order, emptied after each read. */
function sent() {
const calls = broadcasts.broadcastToUser.mock.calls
.map(([to, msg, sid]) => [to, String(msg.type), sid] as const)
.sort((a, b) => a[0] - b[0]);
broadcasts.broadcastToUser.mockClear();
return calls;
}
/** A journey with its owner, an editor and a viewer, and a blank book the owner laid down over MCP. */
async function journeyWithBook() {
const { user: owner } = await makeUser(orm);
const { user: editor } = await makeUser(orm);
const { user: viewer } = await makeUser(orm);
const journey = await makeJourney(orm, owner.id);
await addJourneyContributor(orm, journey.id, editor.id, 'editor');
await addJourneyContributor(orm, journey.id, viewer.id, 'viewer');
const created = bookWriteResultSchema.parse(await callTool(owner.id, 'create_book', { journeyId: journey.id }));
const inner = created.pages.find((p) => p.role === 'inner')?.id ?? 'no inner page';
sent();
return { owner, editor, viewer, journey, inner, everyone: [owner.id, editor.id, viewer.id].sort((a, b) => a - b) };
}
describe('Studio book over MCP and REST', () => {
it('JBE2E-001: an MCP write is what GET reads next, and a viewer is refused in the words a tool answers', async () => {
const { editor, viewer, journey, inner } = await journeyWithBook();
const edited = bookWriteResultSchema.parse(
await callTool(editor.id, 'edit_book_page', {
journeyId: journey.id,
spreadId: inner,
ops: [{ op: 'background', color: '#123456' }],
}),
);
const res = await request(app.getHttpServer())
.get(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(viewer.id));
expect(res.status).toBe(200);
expect(res.body.book.version).toBe(edited.version);
expect(res.body.book.document.spreads[2].background).toBe('#123456');
expect(
await callTool(viewer.id, 'edit_book_page', {
journeyId: journey.id,
spreadId: inner,
ops: [{ op: 'background', color: null }],
}),
).toEqual({ error: "You can read this journey's book but not change it: only its owner and editors can." });
});
it('JBE2E-002: a REST save and an MCP write send the same event to the same users, REST leaving out its tab', async () => {
const { owner, journey, inner, everyone } = await journeyWithBook();
const before = await request(app.getHttpServer())
.get(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id));
const saved = await request(app.getHttpServer())
.put(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id))
.set('X-Socket-Id', 'sock-rest')
.send({ title: before.body.book.title, document: before.body.book.document, baseVersion: 1 });
expect(saved.status).toBe(200);
expect(sent()).toEqual(everyone.map((id) => [id, 'journey:book:saved', 'sock-rest']));
const paper = [{ op: 'background', color: '#fafafa' }];
await callTool(owner.id, 'edit_book_page', { journeyId: journey.id, spreadId: inner, ops: paper });
expect(sent()).toEqual(everyone.map((id) => [id, 'journey:book:saved', undefined]));
});
it('JBE2E-004: an operation the engine refuses reaches the client as the tool error naming it, and nothing is written', async () => {
const { owner, journey, inner } = await journeyWithBook();
const refused = await callTool(owner.id, 'edit_book_page', {
journeyId: journey.id,
spreadId: inner,
ops: [
{ op: 'background', color: '#101010' },
{ op: 'place_photo', photoId: 987654, target: 'new' },
],
});
expect(refused).toEqual({ error: "ops[1]: photo 987654 is not in this journey's gallery" });
expect(sent()).toEqual([]);
const res = await request(app.getHttpServer())
.get(`/api/journeys/${journey.id}/book`)
.set('Cookie', authCookie(owner.id));
expect([res.body.book.version, res.body.book.document.spreads[2].background]).toEqual([1, null]);
});
it('JBE2E-003: the book tools are gone while the Journey addon is off', async () => {
const { user } = await makeUser(orm);
await setAddonEnabled(orm, ADDON_IDS.JOURNEY, false);
const names = await session(
user.id,
() => true,
async (client) => (await client.listTools()).tools.map((tool) => tool.name),
);
expect(names.filter((name) => BOOK_TOOLS.includes(name))).toEqual([]);
expect(names.length).toBeGreaterThan(0);
});
});