test(assistant): book card tests run in a request context and match a journey without a book

The book card e2e calls the book services inside a request context, the
preview expects the design id beside the operations, a trial without a book
has no version, the JPEG fixture keeps its signature, and the upload test
checks the file name where the client sets it.
This commit is contained in:
Maurice
2026-10-22 22:43:48 +00:00
parent 3b9edc3a90
commit 6c4d743259
5 changed files with 34 additions and 20 deletions
+4 -1
View File
@@ -145,6 +145,7 @@ describe('assistantRepo', () => {
const image = new Blob(['jpeg'], { type: 'image/jpeg' });
const page = { image, bookVersion: 4, spreadId: 'sp-3', drawnPhotoIds: [7, 8] };
const signal = new AbortController().signal;
const append = vi.spyOn(FormData.prototype, 'append');
await assistantRepo.uploadRender(12, 9, 'req-1', page, signal);
await expect(assistantRepo.pageBook(12)).resolves.toBeNull();
@@ -152,9 +153,11 @@ describe('assistantRepo', () => {
await expect(assistantRepo.pageDraft(12, 'd-1')).resolves.toEqual({ version: 0, document: DRAFT });
expect(requestId).toBe('req-1');
// One file part; jsdom names every file part 'blob' on the wire, so its name is checked where the form gets it.
expect(append).toHaveBeenCalledWith('image', image, 'page.jpg');
expect(sent).toEqual({
fields: { bookVersion: '4', spreadId: 'sp-3', drawnPhotoIds: '7,8' },
filenames: ['page.jpg'],
filenames: [expect.any(String)],
});
// Online only, like every other call here.
@@ -16,6 +16,7 @@
* 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';
@@ -24,7 +25,7 @@ import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { UiStreamChunk } from '@trek/shared';
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';
@@ -166,11 +167,17 @@ async function stream(path: string, userId: number, body: object): Promise<UiStr
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 books.createBook(journey.id, user.id, {});
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` };
}
@@ -241,11 +248,11 @@ describe('Studio book cards e2e', () => {
});
// 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((await books.overview(j.journeyId, j.userId)).spreads[2]?.emptyFrames).toBe(2);
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 books.overview(j.journeyId, j.userId);
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' });
});
@@ -259,7 +266,7 @@ describe('Studio book cards e2e', () => {
{ type: 'tool-output-error', toolCallId: 'call_out', errorText: expect.stringMatching(/^ops\[0\]: /) },
]);
expect(ofType(turn, 'tool-approval-request')).toHaveLength(0);
expect((await books.overview(j.journeyId, j.userId)).version).toBe(1);
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 () => {
@@ -271,14 +278,15 @@ describe('Studio book cards e2e', () => {
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 books.overview(j.journeyId, j.userId)).spreads).toHaveLength(7);
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.
await books.editPage(j.journeyId, j.userId, { spreadId: j.inner, ops: [{ op: 'background', color: '#f4efe6' }] });
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 books.overview(j.journeyId, j.userId)).spreads).toHaveLength(7);
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 () => {
@@ -288,15 +296,15 @@ describe('Studio book cards e2e', () => {
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 books.overview(j.journeyId, j.userId)).version).toBe(2);
expect((await bookOf(j)).version).toBe(2);
// The Studio saves a heading on the same page after the change.
const heading = [{ op: 'text', role: 'heading', text: 'Alfama' }];
await books.editPage(j.journeyId, j.userId, { spreadId: j.inner, ops: heading });
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 books.overview(j.journeyId, j.userId);
const after = await bookOf(j);
expect([after.version, after.spreads[2]?.elements]).toEqual([3, 1]);
});
@@ -308,14 +316,14 @@ describe('Studio book cards e2e', () => {
expect(held).toHaveLength(2);
const threadId = ofType(first, 'data-trek-thread')[0]?.data.threadId ?? 0;
await approve(j.base, j.userId, threadId, ...held);
expect((await books.overview(j.journeyId, j.userId)).version).toBe(3);
expect((await bookOf(j)).version).toBe(3);
// Newest first, as "undo all" runs: the newer change goes back, the older is behind the version that left.
expect((await undo(j.base, j.userId, held[1] ?? '')).status).toBe(200);
const older = await undo(j.base, j.userId, held[0] ?? '');
expect(older.status).toBe(409);
expect(older.body).toMatchObject({ code: 'stale' });
expect((await books.overview(j.journeyId, j.userId)).version).toBe(4);
expect((await bookOf(j)).version).toBe(4);
});
it('ASTBKE2E-006: a preview shows a proposed page in words, saves nothing, asks a text model no image', async () => {
@@ -326,7 +334,8 @@ describe('Studio book cards e2e', () => {
const turn = await stream(`${j.base}/chat`, j.userId, body);
const offered = provider.requests[0]?.tools?.find((tool) => tool.function.name === 'preview_book_page');
expect(Object.keys(offered?.function.parameters.properties ?? {})).toEqual(['spreadId', 'ops']);
// 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');
@@ -334,6 +343,6 @@ describe('Studio book cards e2e', () => {
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 books.overview(j.journeyId, j.userId)).version).toBe(1);
expect((await bookOf(j)).version).toBe(1);
});
});
@@ -78,7 +78,7 @@ function setup(fakes: Fakes = {}) {
books as unknown as JourneyBookEditService,
photos as unknown as JourneyAiPhotosService,
registry,
orm,
orm.orm,
);
const stop = new AbortController();
const asked: AssistantPageRender[] = [];
@@ -70,12 +70,13 @@ function book(): BookDocument {
return blankBook({ title: 'Lisbon', newId: (prefix) => `${prefix}-${++seq}` });
}
/** The base a turn's gate tries a call on, as `trialBase` reads it: a journey without a book has no version. */
function input(doc: BookDocument | null = book()): BookTrialInput {
let seq = 0;
return {
journeyId: 4,
doc,
version: 3,
version: doc ? 3 : null,
ctx: {
photos: [{ photoId: 81 }, { photoId: 82 }],
journey: { title: 'Lisbon' },
@@ -23,12 +23,13 @@ function webp(chunk: string, length: number): Buffer {
describe('readImageHeader', () => {
it('IMGHDR-001: steps over stray bytes, fill bytes and the markers that carry no length', () => {
// A JPEG opens with SOI and a marker (FF D8 FF); the stray bytes come between segments, after the first one.
const segments = [
[0xff, 0xe1, 0x00, 0x04, 0, 0],
[0x00, 0x12],
[0xff, 0xff],
[0xff, 0x01],
[0xff, 0xd0],
[0xff, 0xe1, 0x00, 0x04, 0, 0],
];
expect(readImageHeader(jpeg(800, 600, ...segments))).toEqual({ mimeType: 'image/jpeg', width: 800, height: 600 });
});