Files
TREK/server/tests/e2e/assistant-book-cards.e2e.test.ts
T
Maurice 72c61f242e fix(assistant): book changes undo per page, and the Studio prompt fits the user's rights
Changes held in one step are applied in the order the chat shows them, so
"undo all" walks them back newest first. A page put back is checked against
the digest of the page as the change left it, so a save to another page no
longer blocks the undo; restore_book_page takes these digests as an option and
keeps the whole book check without them. The book card counts the user's own
photos, the preview tool describes itself per role, a copy into another trip
names no places, a held vacay toggle says up front whether it can be taken
back, and a viewer's Studio prompt names none of the design's tools.
2026-10-23 01:45:16 +00:00

377 lines
19 KiB
TypeScript

/**
* The book's change cards in TREK Studio, end to end: the whole app (buildApp,
* every global guard, the Zod pipe, the response contracts, the real MCP
* registry) with an instance connection of kind `custom` pointing at a fake
* OpenAI-compatible server on loopback, scripted per case.
*
* Covered: a page edit that would leave empty frames goes back to the model
* once, the same call made again is held with its page card (pages, the
* operations, the findings), applied with a receipt card, and taken back in
* one undo while nothing was saved since; an operation the engine refuses
* never reaches the user; new pages are not taken back once the book moved on,
* and neither is an edited page once that page was saved since (the restore
* checks the page as the change left it). A save to another page does not
* hold an edit's undo back, so of two changes to one page applied together
* "undo all" takes back the newer, then the older; changes apply in the order
* the decisions name them. A page is previewed with the ops of an edit before
* it is proposed, nothing saved; on this text-only model the preview is the
* page's description alone.
*/
import { buildApp } from '../../src/bootstrap';
import { withRequestContext } from '../../src/nest/database/request-context';
import { JourneyBookEditService } from '../../src/nest/journey/journey-book-edit.service';
import { makeJourney } from '../helpers/factories/journeys';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeUser } from '../helpers/factories/users';
import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { BookPageOp, UiStreamChunk } from '@trek/shared';
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// ── The fake provider ─────────────────────────────────────────────────────────
interface ChatBody {
model: string;
messages: Array<{ role: string; content: string | null; tool_call_id?: string }>;
tools?: Array<{ function: { name: string; parameters: { properties?: Record<string, unknown> } } }>;
}
type Step = (body: ChatBody) => { chunks: object[] };
const provider = { requests: [] as ChatBody[], script: [] as Step[] };
const say =
(text: string): Step =>
() => ({
chunks: [
{ choices: [{ index: 0, delta: { content: text } }] },
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
{ choices: [], usage: { prompt_tokens: 12, completion_tokens: 4 } },
],
});
const callTools =
(...calls: Array<{ id: string; name: string; args: Record<string, unknown> }>): Step =>
() => ({
chunks: [
{
choices: [
{
index: 0,
delta: {
tool_calls: calls.map((call, index) => ({
index,
id: call.id,
type: 'function',
function: { name: call.name, arguments: JSON.stringify(call.args) },
})),
},
},
],
},
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
],
});
const callTool = (id: string, name: string, args: Record<string, unknown>): Step => callTools({ id, name, args });
async function readBody(req: IncomingMessage): Promise<string> {
let text = '';
for await (const chunk of req) text += String(chunk);
return text;
}
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method === 'GET' && req.url?.startsWith('/api/tags')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ models: [{ name: 'fake-model', size: 1 }] }));
return;
}
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
const body = JSON.parse(await readBody(req)) as ChatBody;
provider.requests.push(body);
const reply = (provider.script.shift() ?? say('Nothing more.'))(body);
if (res.destroyed) return;
res.writeHead(200, { 'content-type': 'text/event-stream' });
for (const chunk of reply.chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
res.end('data: [DONE]\n\n');
}
// ── The app ──────────────────────────────────────────────────────────────────
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
let books: JourneyBookEditService;
beforeAll(async () => {
fake = createServer((req, res) => {
answer(req, res).catch(() => res.destroy());
});
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
nestApp = await buildApp();
orm = nestApp.get(MikroORM);
books = nestApp.get(JourneyBookEditService);
await setAddonEnabled(orm, 'ai_assistant', true);
await setAddonEnabled(orm, 'journey', true);
const adminId = (await makeUser(orm, { role: 'admin' })).user.id;
const { port } = fake.address() as AddressInfo;
const res = await request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(adminId))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
expect(res.status).toBe(200);
});
beforeEach(async () => {
await resetRateLimits(nestApp);
provider.requests = [];
provider.script = [];
});
afterAll(async () => {
await nestApp.close();
await new Promise<void>((resolve) => fake.close(() => resolve()));
});
// ── Helpers ──────────────────────────────────────────────────────────────────
const CTX = { locale: 'en', timeZone: 'Europe/Lisbon' };
async function stream(path: string, userId: number, body: object): Promise<UiStreamChunk[]> {
const res = await request(nestApp.getHttpServer())
.post(path)
.set('Cookie', sessionCookie(userId))
.send(body)
.buffer(true)
.parse((s, done) => {
let text = '';
s.setEncoding('utf8');
s.on('data', (chunk: string) => (text += chunk));
s.on('end', () => done(null, text));
});
expect(res.status).toBe(200);
return (res.body as string)
.split('\n\n')
.filter((event) => event.startsWith('data: ') && event !== 'data: [DONE]')
.map((event) => JSON.parse(event.slice('data: '.length)) as UiStreamChunk);
}
const ofType = <T extends UiStreamChunk['type']>(chunks: UiStreamChunk[], type: T) =>
chunks.filter((chunk): chunk is Extract<UiStreamChunk, { type: T }> => chunk.type === type);
/** A call on the book service as the Studio's own save makes it: in a request's ORM context, as HTTP runs one. */
const inContext = <T>(work: () => Promise<T>): Promise<T> => withRequestContext(orm, work);
/** The book as `get_book` reads it for the journey's owner. */
const bookOf = (j: { journeyId: number; userId: number }) => inContext(() => books.overview(j.journeyId, j.userId));
/** A user with a journey of their own and a blank book in it: its double page is `inner`. */
async function ownerWithBook() {
const { user } = await makeUser(orm);
const journey = await makeJourney(orm, user.id, { title: 'Portugal by train' });
const created = await inContext(() => books.createBook(journey.id, user.id, {}));
const inner = created.pages.find((page) => page.role === 'inner')?.id ?? 'no inner page';
return { userId: user.id, journeyId: journey.id, inner, base: `/api/journeys/${journey.id}/assistant` };
}
/** Approves held changes, in this order, and answers what the model says next. */
function approve(base: string, userId: number, threadId: number, ...toolCallIds: string[]) {
provider.script = [say('Done.')];
return stream(`${base}/threads/${threadId}/decisions`, userId, {
...CTX,
decisions: toolCallIds.map((toolCallId) => ({ toolCallId, approved: true })),
});
}
/** One page's paper changed by the model: no fault, so the gate holds it at once. */
const paper = (id: string, spreadId: string, color: string) => ({
id,
name: 'edit_book_page',
args: { spreadId, ops: [{ op: 'background', color }] },
});
function undo(base: string, userId: number, toolCallId: string) {
return request(nestApp.getHttpServer())
.post(`${base}/tool-calls/${toolCallId}/undo`)
.set('Cookie', sessionCookie(userId))
.send({});
}
const TWO_UP = [{ op: 'layout', template: 'two-up' }];
// ── Cases ────────────────────────────────────────────────────────────────────
describe('Studio book cards e2e', () => {
it('ASTBKE2E-001: a page left with empty frames goes back once, is held with its card, applied and taken back', async () => {
const j = await ownerWithBook();
const edit = { spreadId: j.inner, ops: TWO_UP };
provider.script = [callTool('call_1', 'edit_book_page', edit), callTool('call_2', 'edit_book_page', edit)];
const turn = await stream(`${j.base}/chat`, j.userId, { ...CTX, threadId: null, text: 'Two photos here' });
const [bounced] = ofType(turn, 'tool-output-error');
expect(bounced).toMatchObject({ toolCallId: 'call_1', errorText: expect.stringContaining('Not proposed yet') });
expect(bounced?.errorText).toContain('empty_frame');
const toModel = provider.requests[1]?.messages.find((message) => message.tool_call_id === 'call_1');
expect(toModel?.content).toContain('Not proposed yet');
const [held] = ofType(turn, 'tool-approval-request');
const [change] = ofType(turn, 'data-trek-change');
expect(change?.data).toMatchObject({ verbKey: 'editBookPage', undoable: true });
expect(change?.data.card).toMatchObject({
kind: 'bookPage',
journeyId: j.journeyId,
change: 'changed',
baseVersion: 1,
pages: [{ spreadId: j.inner, role: 'inner', number: 1 }],
ops: [{ op: 'layout', template: 'two-up' }],
layout: { kind: 'template', id: 'two-up', name: null },
});
expect(change?.data.card?.kind === 'bookPage' && change.data.card.findings).toContainEqual({
code: 'empty_frame',
severity: 'severe',
});
const threadId = ofType(turn, 'data-trek-thread')[0]?.data.threadId ?? 0;
const decided = await approve(j.base, j.userId, threadId, held?.toolCallId ?? '');
const [receipt] = ofType(decided, 'data-trek-card');
expect(receipt?.data).toMatchObject({
toolCallId: held?.toolCallId,
card: { kind: 'bookPage', baseVersion: 2, pages: [{ spreadId: j.inner }] },
});
// The pages the answer kept for the undo never reach the model or the chat.
expect(JSON.stringify(ofType(decided, 'tool-output-available'))).not.toContain('previous_spreads');
expect(JSON.stringify(ofType(decided, 'tool-output-available'))).not.toContain('page_digests');
expect((await bookOf(j)).spreads[2]?.emptyFrames).toBe(2);
const undone = await undo(j.base, j.userId, held?.toolCallId ?? '');
expect(undone.status).toBe(200);
const after = await bookOf(j);
expect([after.version, after.spreads[2]?.elements]).toEqual([3, 0]);
expect((await undo(j.base, j.userId, held?.toolCallId ?? '')).body).toMatchObject({ code: 'stale' });
});
it('ASTBKE2E-002: an operation the engine refuses goes back to the model at once and is never held', async () => {
const j = await ownerWithBook();
const outside = { spreadId: j.inner, ops: [{ op: 'place_photo', photoId: 999_999, target: 'new' }] };
provider.script = [callTool('call_out', 'edit_book_page', outside), say('That photo is not in the journey.')];
const turn = await stream(`${j.base}/chat`, j.userId, { ...CTX, threadId: null, text: 'Use my best photo' });
expect(ofType(turn, 'tool-output-error')).toEqual([
{ type: 'tool-output-error', toolCallId: 'call_out', errorText: expect.stringMatching(/^ops\[0\]: /) },
]);
expect(ofType(turn, 'tool-approval-request')).toHaveLength(0);
expect((await bookOf(j)).version).toBe(1);
});
it('ASTBKE2E-003: new pages are deleted again by undo, but not once the book was saved since', async () => {
const j = await ownerWithBook();
const add = { afterSpreadId: j.inner, count: 2 };
provider.script = [callTool('call_add', 'add_book_pages', add)];
const first = await stream(`${j.base}/chat`, j.userId, { ...CTX, threadId: null, text: 'Two more pages' });
const [held] = ofType(first, 'tool-approval-request');
expect(ofType(first, 'data-trek-change')[0]?.data.card).toMatchObject({ change: 'new', count: 2 });
const threadId = ofType(first, 'data-trek-thread')[0]?.data.threadId ?? 0;
await approve(j.base, j.userId, threadId, held?.toolCallId ?? '');
expect((await bookOf(j)).spreads).toHaveLength(7);
// Someone saves the book after the change: the undo would delete pages that are no longer just as added.
const warmer: BookPageOp[] = [{ op: 'background', color: '#f4efe6' }];
await inContext(() => books.editPage(j.journeyId, j.userId, { spreadId: j.inner, ops: warmer }));
const stale = await undo(j.base, j.userId, held?.toolCallId ?? '');
expect(stale.status).toBe(409);
expect(stale.body).toMatchObject({ code: 'stale' });
expect((await bookOf(j)).spreads).toHaveLength(7);
});
it('ASTBKE2E-004: an edited page is not put back once the book was saved since; the page keeps that save', async () => {
const j = await ownerWithBook();
provider.script = [callTools(paper('call_bg', j.inner, '#f4efe6'))];
const first = await stream(`${j.base}/chat`, j.userId, { ...CTX, threadId: null, text: 'Warmer paper' });
const [held] = ofType(first, 'tool-approval-request');
const threadId = ofType(first, 'data-trek-thread')[0]?.data.threadId ?? 0;
await approve(j.base, j.userId, threadId, held?.toolCallId ?? '');
expect((await bookOf(j)).version).toBe(2);
// The Studio saves a heading on the same page after the change.
const heading: BookPageOp[] = [{ op: 'text', role: 'heading', text: 'Alfama' }];
await inContext(() => books.editPage(j.journeyId, j.userId, { spreadId: j.inner, ops: heading }));
const stale = await undo(j.base, j.userId, held?.toolCallId ?? '');
expect(stale.status).toBe(409);
expect(stale.body).toMatchObject({ code: 'stale' });
const after = await bookOf(j);
expect([after.version, after.spreads[2]?.elements]).toEqual([3, 1]);
});
it('ASTBKE2E-005: of two changes to one page applied together, "undo all" takes back the newer, then the older', async () => {
const j = await ownerWithBook();
provider.script = [callTools(paper('call_a', j.inner, '#f4efe6'), paper('call_b', j.inner, '#e6eef4'))];
const first = await stream(`${j.base}/chat`, j.userId, { ...CTX, threadId: null, text: 'Try two papers' });
const held = ofType(first, 'tool-approval-request').map((part) => part.toolCallId);
expect(held).toHaveLength(2);
const threadId = ofType(first, 'data-trek-thread')[0]?.data.threadId ?? 0;
await approve(j.base, j.userId, threadId, ...held);
// Applied in the order the decisions name them, which is the order they were proposed in: the second paper stays.
const applied = await bookOf(j);
expect([applied.version, applied.spreads[2]?.background]).toEqual([3, '#e6eef4']);
// Newest first, as "undo all" runs: each restore checks only its own page, as its change left it.
expect((await undo(j.base, j.userId, held[1] ?? '')).status).toBe(200);
expect((await bookOf(j)).spreads[2]?.background).toBe('#f4efe6');
expect((await undo(j.base, j.userId, held[0] ?? '')).status).toBe(200);
const after = await bookOf(j);
expect([after.version, after.spreads[2]?.background]).toEqual([5, null]);
});
it('ASTBKE2E-006: a preview shows a proposed page in words, saves nothing, asks a text model no image', async () => {
const j = await ownerWithBook();
const edit = { spreadId: j.inner, ops: [{ op: 'background', color: '#f4efe6' }] };
provider.script = [callTools({ id: 'call_look', name: 'preview_book_page', args: edit }), say('It reads well.')];
const body = { ...CTX, threadId: null, text: 'Warmer paper?', renders: true };
const turn = await stream(`${j.base}/chat`, j.userId, body);
const offered = provider.requests[0]?.tools?.find((tool) => tool.function.name === 'preview_book_page');
// With the edit's ops the preview also takes a design draft's id (ASTPVW-005).
expect(Object.keys(offered?.function.parameters.properties ?? {})).toEqual(['spreadId', 'ops', 'designId']);
expect(ofType(turn, 'tool-output-available').map((part) => part.toolCallId)).toEqual(['call_look']);
expect(ofType(turn, 'data-trek-render-request')).toEqual([]);
const [view] = ofType(turn, 'data-trek-page-view');
expect(view?.data).toMatchObject({ spreadId: j.inner, bookVersion: 1, width: null, height: null, bytes: null });
const answer = provider.requests[1]?.messages.find((message) => message.tool_call_id === 'call_look');
expect(answer?.content).toContain('"proposed":true');
expect(answer?.content).toContain('Findings');
expect((await bookOf(j)).version).toBe(1);
});
it('ASTBKE2E-007: an edited page is put back though another page was saved since, and that save stays', async () => {
const j = await ownerWithBook();
provider.script = [callTools(paper('call_bg', j.inner, '#f4efe6'))];
const first = await stream(`${j.base}/chat`, j.userId, { ...CTX, threadId: null, text: 'Warmer paper' });
const [held] = ofType(first, 'tool-approval-request');
const threadId = ofType(first, 'data-trek-thread')[0]?.data.threadId ?? 0;
await approve(j.base, j.userId, threadId, held?.toolCallId ?? '');
// The Studio saves the first page after the change: the book's version moves on, the edited page does not.
const firstPage = (await bookOf(j)).spreads.find((spread) => spread.role === 'first')?.id ?? 'no first page';
const dark: BookPageOp[] = [{ op: 'background', color: '#101010' }];
await inContext(() => books.editPage(j.journeyId, j.userId, { spreadId: firstPage, ops: dark }));
expect((await undo(j.base, j.userId, held?.toolCallId ?? '')).status).toBe(200);
const after = await bookOf(j);
expect(after.version).toBe(4);
expect(after.spreads.map((spread) => [spread.id, spread.background])).toEqual(
expect.arrayContaining([
[j.inner, null],
[firstPage, '#101010'],
]),
);
});
});