diff --git a/client/src/repo/assistantRepo.test.ts b/client/src/repo/assistantRepo.test.ts index d034facb5..32283603f 100644 --- a/client/src/repo/assistantRepo.test.ts +++ b/client/src/repo/assistantRepo.test.ts @@ -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. diff --git a/server/tests/e2e/assistant-book-cards.e2e.test.ts b/server/tests/e2e/assistant-book-cards.e2e.test.ts index ab5147a2e..dd9474ac0 100644 --- a/server/tests/e2e/assistant-book-cards.e2e.test.ts +++ b/server/tests/e2e/assistant-book-cards.e2e.test.ts @@ -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(chunks: UiStreamChunk[], type: T) => chunks.filter((chunk): chunk is Extract => 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 = (work: () => Promise): Promise => 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); }); }); diff --git a/server/tests/unit/nest/assistant/assistant-vision.test.ts b/server/tests/unit/nest/assistant/assistant-vision.test.ts index 040b3d0bb..3ac166b01 100644 --- a/server/tests/unit/nest/assistant/assistant-vision.test.ts +++ b/server/tests/unit/nest/assistant/assistant-vision.test.ts @@ -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[] = []; diff --git a/server/tests/unit/nest/assistant/cards/book/book-cards.test.ts b/server/tests/unit/nest/assistant/cards/book/book-cards.test.ts index c338a96c9..7110d4ee1 100644 --- a/server/tests/unit/nest/assistant/cards/book/book-cards.test.ts +++ b/server/tests/unit/nest/assistant/cards/book/book-cards.test.ts @@ -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' }, diff --git a/server/tests/unit/nest/common/image-header.test.ts b/server/tests/unit/nest/common/image-header.test.ts index 9722e2912..a0991bf9b 100644 --- a/server/tests/unit/nest/common/image-header.test.ts +++ b/server/tests/unit/nest/common/image-header.test.ts @@ -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 }); });