/** * TASK-0123 — AI writing tools (SSE) + apply integration suite. * * Full stack (AppModule + Postgres + Redis) against the deterministic * `MockProvider` (`AI_PROVIDER=mock`; the writing mode emits a single * `generateBlocks` tool call derived from the `[SELECTED …]` markers). * * Covers the spec's integration acceptance criteria: * - validation: >50 blocks → 422 `too_many_blocks`; missing action → 400; * - the >100k-char selected-text cap → 413 `PAYLOAD_TOO_LARGE`; * - permission granularity (design decision 1, per-page): a view-only * member can SUGGEST (reads content) but apply fails per block with * `FORBIDDEN {reason:"requires_EDIT"}` while the rest apply (per-block * failure isolation); * - server-side tool-call validation: the mock `forceUnknownId` seam makes * the model emit a forged blockId — it is DROPPED (noted, never applied); * - the concurrent-edit 409 path: apply with a stale suggestion-time * version → per-block `409 CONFLICT` entry carrying `actualVersion`; * - budget exhaustion → 402 AI_BUDGET_EXCEEDED (pre-stream JSON); * - cost logging: the assistant row carries `metadata.feature="writing"`, * `action`, `tokensIn/Out` and `model`; the hidden per-call conversation * never appears in the conversation list; * - apply no-smuggling: blockIds outside the stored suggestion → 422 * `unknown_block_id`; ownership-scoped 404 for a foreign suggestion. */ // Config must be fixed BEFORE the app boots (loadEnv + the workspace cache). process.env.AI_PROVIDER = "mock"; process.env.SEARCH_VECTOR_DIMENSION = "8"; // Pacing keeps the suite fast but lets the SSE reader frame cleanly // (one delta per word of the note; the tool call is a single chunk). process.env.AI_MOCK_DELTA_DELAY_MS = "25"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; // Deep import (no `.js` — vite resolution), the same convention as the // ai-capability suite: the budget guard reads workspaces through a 30 s // cache, so the direct-DB budget writes in this file need the reset to // take effect immediately. const { resetWorkspaceCache } = await import("@factory/api/src/modules/ai/gateway-provider"); let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); // --------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------- type SseEvent = { type: "delta" | "tool_call" | "usage" | "error" | "done"; text?: string; id?: string; name?: string; arguments?: Record; tokensIn?: number; tokensOut?: number; code?: string; message?: string; retryable?: boolean; retryAfterSeconds?: number; assistantMessageId?: string; userMessageId?: string; reason?: string; }; /** POST the writing SSE endpoint, collect the frames. */ async function streamWriting( accessToken: string, workspaceId: string, body: Record, ): Promise<{ status: number; events: SseEvent[]; contentType: string }> { const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/writing`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` }, body: JSON.stringify(body), }); const contentType = res.headers.get("content-type") ?? ""; const events: SseEvent[] = []; if (!res.body) return { status: res.status, events, contentType }; const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ""; for (;;) { const chunk = await reader.read(); if (chunk.done) break; buf += decoder.decode(chunk.value, { stream: true }); let idx: number; while ((idx = buf.indexOf("\n\n")) !== -1) { const frame = buf.slice(0, idx); buf = buf.slice(idx + 2); for (const line of frame.split("\n")) { if (!line.startsWith("data:")) continue; // skip `: keepalive` events.push(JSON.parse(line.slice(5).trim()) as SseEvent); } } } return { status: res.status, events, contentType }; } const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** A page with N PARAGRAPH blocks (distinctive, ≥2-word texts so the mock's * summarize/rewrite transforms are observable). Returns ids in page order. */ async function createPageWithBlocks( accessToken: string, workspaceId: string, title: string, texts: string[], ): Promise<{ pageId: string; blockIds: string[] }> { const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(accessToken), body: { title }, }); expect(pageRes.status).toBe(201); const page = (await pageRes.json()) as { id: string }; let prevId: string | undefined; const blockIds: string[] = []; for (const text of texts) { const r = await app.request("POST", `/api/v1/pages/${page.id}/blocks`, { headers: authHeaders(accessToken), body: { type: "PARAGRAPH", content: { text }, ...(prevId ? { afterBlockId: prevId } : {}) }, }); expect(r.status).toBe(201); const b = (await r.json()) as { id: string }; blockIds.push(b.id); prevId = b.id; } return { pageId: page.id, blockIds }; } /** A fresh workspace with an OWNER user. */ async function freshWorkspace(name: string) { const owner = await createUser(app.request, { name }); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), `${name} WS`); return { owner, workspaceId, h: authHeaders(owner.accessToken!) }; } /** The suggestion persisted by a finished stream. */ async function suggestionFrom(events: SseEvent[]) { const done = events.find((e) => e.type === "done"); expect(done?.reason).toBe("ok"); const suggestionId = done!.assistantMessageId!; const row = await app.prisma.aiMessage.findUniqueOrThrow({ where: { id: suggestionId } }); return { done, suggestionId, row }; } // --------------------------------------------------------------------------- // happy path // --------------------------------------------------------------------------- describe("TASK-0123 writing — SSE happy path", () => { it("summarize 3 blocks → suggestion; apply → all 3 updated as NORMAL block updates (audit + versions + positions unchanged)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Writing Owner"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Doc", [ "The first paragraph talks about the launch plan and its milestones in detail.", "The second paragraph explains the budget, the staffing model, and the risks ahead.", "The third paragraph summarizes the decision criteria the team agreed to follow.", ]); const out = await streamWriting(owner.accessToken!, workspaceId, { action: "summarize", pageId, blockIds, }); expect(out.status).toBe(200); expect(out.contentType).toContain("text/event-stream"); const types = out.events.map((e) => e.type); expect(types[types.length - 1]).toBe("done"); expect(types).toContain("tool_call"); expect(types).toContain("usage"); // the mock's summarize transform is observable in the suggested text const tool = out.events.find((e) => e.type === "tool_call")!; expect(tool.name).toBe("generateBlocks"); const suggested = (tool.arguments as { blocks: Array<{ blockId: string; newText: string }> }).blocks; expect(suggested.map((b) => b.blockId)).toEqual(blockIds); for (const b of suggested) expect(b.newText).toMatch(/^Summarized: /); // usage frame + persisted tokens (the cost ledger input) const usage = out.events.find((e) => e.type === "usage")!; expect(usage.tokensIn).toBeGreaterThan(0); expect(usage.tokensOut).toBeGreaterThan(0); // ── the persisted suggestion (suggestionId = the assistant message id) ── const { suggestionId, row } = await suggestionFrom(out.events); expect(row.role).toBe("assistant"); const meta = row.metadata as { feature: string; action: string }; expect(meta.feature).toBe("writing"); expect(meta.action).toBe("summarize"); expect(row.tokensIn).toBe(usage.tokensIn); expect(row.tokensOut).toBe(usage.tokensOut); expect(row.model).toBeTruthy(); const suggestion = row.toolResults as { blocks: Array<{ blockId: string; newText: string; version: number; content: { text: string }; text: string }>; droppedIds: string[] }; expect(suggestion.droppedIds).toEqual([]); expect(suggestion.blocks.map((b) => b.blockId)).toEqual(blockIds); // the suggestion snapshots the ORIGINAL text (the UI's diff old-side) expect(suggestion.blocks[0]!.text).toBe("The first paragraph talks about the launch plan and its milestones in detail."); expect(suggestion.blocks[0]!.content.text).toBe("The first paragraph talks about the launch plan and its milestones in detail."); // the hidden per-call conversation must NOT be listed const listRes = await app.request("GET", `/api/v1/workspaces/${workspaceId}/ai/conversations`, { headers: h }); const list = (await json(listRes)) as { data: Array<{ id: string; title: string }> }; expect(list.data.map((c) => c.title)).not.toContain("writing:summarize"); // ── positions before (apply must not move blocks) ── const before = await app.prisma.block.findMany({ where: { id: { in: blockIds } }, orderBy: { position: "asc" } }); const positionsBefore = before.map((b) => b.position); const versionsBefore = before.map((b) => b.version); // ── apply (all blocks) ── const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, { headers: h, body: { suggestionId }, }); expect(applyRes.status).toBe(200); const apply = (await json(applyRes)) as { results: Array<{ blockId: string; status: string }> }; expect(apply.results).toHaveLength(3); for (const r of apply.results) expect(r.status).toBe("applied"); // ── the writes landed as NORMAL block updates ── const after = await app.prisma.block.findMany({ where: { id: { in: blockIds } }, orderBy: { position: "asc" } }); expect(after.map((b) => b.position)).toEqual(positionsBefore); // positions unchanged expect(after.map((b) => b.version)).toEqual(versionsBefore.map((v) => v + 1)); // versions bumped const byId = new Map(blockIds.map((id, i) => [id, after[i]!])); for (const b of suggested) { const block = byId.get(b.blockId)!; expect((block.content as { text: string }).text).toBe(b.newText); expect(block.updatedBy).toBe(owner.userId); // a normal update, not "ai" } // audit: three `block.updated` (normal) + one `ai.writing_applied` const audit = await app.prisma.auditLog.findMany({ where: { workspaceId, action: { in: ["block.updated", "ai.writing_applied"] } }, }); expect(audit.filter((a) => a.action === "block.updated")).toHaveLength(3); const applied = audit.find((a) => a.action === "ai.writing_applied"); expect(applied).toBeTruthy(); expect((applied!.metadata as { applied: number; failed: number }).applied).toBe(3); }); it("translate with a language: the language rides into the prompt + the suggestion row", async () => { const { owner, workspaceId, h } = await freshWorkspace("Translate Owner"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Intl", [ "Hello there, this is a friendly greeting for the international reader.", ]); const out = await streamWriting(owner.accessToken!, workspaceId, { action: "translate", language: "Spanish", pageId, blockIds, }); const tool = out.events.find((e) => e.type === "tool_call")!; const suggested = (tool.arguments as { blocks: Array<{ newText: string }> }).blocks; expect(suggested[0]!.newText).toBe("Translated to Spanish: Hello there, this is a friendly greeting for the international reader."); const { row } = await suggestionFrom(out.events); const meta = row.metadata as { feature: string; action: string; language: string }; expect(meta).toEqual({ feature: "writing", action: "translate", language: "Spanish" }); // apply the single block const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, { headers: h, body: { suggestionId: row.id }, }); expect(applyRes.status).toBe(200); }); }); // --------------------------------------------------------------------------- // validation (pre-stream JSON envelope) // --------------------------------------------------------------------------- describe("TASK-0123 writing — pre-stream validation", () => { it(">50 blocks → 422 too_many_blocks (the spec's canonical code, not 400)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Cap Owner"); const tooMany = Array.from({ length: 51 }, (_, i) => `fake-block-id-${i}`); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, { headers: h, body: { action: "improve", pageId: "page-x", blockIds: tooMany }, }); expect(res.status).toBe(422); const body = await json(res); expect(body.error.code).toBe("VALIDATION_ERROR"); const reason = (body.error.details as Array<{ reason?: string }>)?.[0]?.reason; expect(reason).toBe("too_many_blocks"); }); it("bad action → 400 VALIDATION_ERROR (zod enum)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Enum Owner"); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, { headers: h, body: { action: "definitely-not-an-action", pageId: "page-x", blockIds: ["b1"] }, }); expect(res.status).toBe(400); }); it("selected text over 100k chars → 413 PAYLOAD_TOO_LARGE (before any SSE byte)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Big Owner"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Big", [ "z".repeat(100_001), ]); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, { headers: h, body: { action: "rewrite", pageId, blockIds }, }); expect(res.status).toBe(413); const body = await json(res); expect(body.error.code).toBe("PAYLOAD_TOO_LARGE"); }); it("no editable blocks (all missing) → 422 no_editable_blocks", async () => { const { owner, workspaceId, h } = await freshWorkspace("Empty Owner"); const { pageId } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Empty", ["some text here"]); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, { headers: h, body: { action: "rewrite", pageId, blockIds: ["never-existed-1", "never-existed-2"] }, }); expect(res.status).toBe(422); const body = await json(res); const reason = (body.error.details as Array<{ reason?: string }>)?.[0]?.reason; expect(reason).toBe("no_editable_blocks"); }); }); // --------------------------------------------------------------------------- // permissions (per-page granularity — design decision 1) // --------------------------------------------------------------------------- describe("TASK-0123 writing — per-page permission granularity", () => { it("a view-only member can SUGGEST; apply fails per block with FORBIDDEN {reason:'requires_EDIT'}", async () => { const { owner, workspaceId, h } = await freshWorkspace("Perm Owner"); const guest = await createUser(app.request, { name: "View Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Shared", [ "Shared document paragraph one with some meaningful text inside it.", ]); // the GUEST has workspace-wide VIEW (GUEST role) → suggest works const out = await streamWriting(guest.accessToken!, workspaceId, { action: "improve", pageId, blockIds, }); expect(out.status).toBe(200); const tool = out.events.find((e) => e.type === "tool_call")!; expect((tool.arguments as { blocks: unknown[] }).blocks).toHaveLength(1); const { row } = await suggestionFrom(out.events); // apply → the per-block EDIT re-check fails (GUEST = VIEW) const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, { headers: authHeaders(guest.accessToken!), body: { suggestionId: row.id }, }); expect(applyRes.status).toBe(200); // per-block isolation, not a hard fail const apply = (await json(applyRes)) as { results: Array<{ blockId: string; status: string; code: string }> }; expect(apply.results[0]).toMatchObject({ status: "failed", code: "FORBIDDEN" }); // and the block is untouched const block = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[0]! } }); expect((block.content as { text: string }).text).toBe("Shared document paragraph one with some meaningful text inside it."); }); it("a page-level EDIT grant lets the same member apply (grant beats role)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Grant Owner"); const guest = await createUser(app.request, { name: "Edit Grant Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Grant Page", [ "Grant document paragraph with a sentence the guest should be able to improve.", ]); // owner grants the guest EDIT on THIS page only. (The public grant API // does not exist in this codebase yet — `permissions.integration.test.ts` // is one of the known-failing integration specs — so the grant row is // seeded through the DB, exactly like the budget-limit writes here.) await app.prisma.permission.create({ data: { workspaceId, granteeType: "user", granteeId: guest.userId, resourceType: "page", resourceId: pageId, level: "EDIT", }, }); const out = await streamWriting(guest.accessToken!, workspaceId, { action: "improve", pageId, blockIds }); expect(out.status).toBe(200); const { row } = await suggestionFrom(out.events); const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, { headers: authHeaders(guest.accessToken!), body: { suggestionId: row.id }, }); expect(applyRes.status).toBe(200); const apply = (await json(applyRes)) as { results: Array<{ status: string }> }; expect(apply.results[0]!.status).toBe("applied"); }); it("apply of a foreign user's suggestion → 404 (existence hidden)", async () => { const a = await freshWorkspace("A"); const b = await freshWorkspace("B"); const { pageId, blockIds } = await createPageWithBlocks(a.owner.accessToken!, a.workspaceId, "A", ["Something to summarize here"]); const out = await streamWriting(a.owner.accessToken!, a.workspaceId, { action: "summarize", pageId, blockIds }); const { row } = await suggestionFrom(out.events); const res = await app.request("POST", `/api/v1/workspaces/${b.workspaceId}/ai/writing/apply`, { headers: b.h, body: { suggestionId: row.id }, }); expect(res.status).toBe(404); }); }); // --------------------------------------------------------------------------- // server-side tool-call validation (the unknown-ID seam) // --------------------------------------------------------------------------- describe("TASK-0123 writing — tool-call validation", () => { it("a forged blockId from the model is DROPPED + noted (never applied)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Forged Owner"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Forge", [ "Forging test paragraph one with a full sentence for the mock to transform.", "Forging test paragraph two with another full sentence for the mock.", ]); // arm the seam (gated like every other internal surface) const armRes = await app.request("POST", `/internal/workspaces/${workspaceId}/ai/mock-unknown-id`, { headers: { Authorization: `Bearer ${app.internalServiceToken}` }, body: { unknownId: "FORGED_BLOCK_ID" }, }); expect(armRes.status).toBe(200); const out = await streamWriting(owner.accessToken!, workspaceId, { action: "rewrite", pageId, blockIds }); const tool = out.events.find((e) => e.type === "tool_call")!; // the raw model call DOES contain the forged id const raw = (tool.arguments as { blocks: Array<{ blockId: string }> }).blocks; expect(raw.map((b) => b.blockId)).toContain("FORGED_BLOCK_ID"); const { row } = await suggestionFrom(out.events); const suggestion = row.toolResults as { blocks: Array<{ blockId: string }>; droppedIds: string[] }; // …but the PERSISTED suggestion drops it + records it expect(suggestion.droppedIds).toEqual(["FORGED_BLOCK_ID"]); expect(suggestion.blocks.map((b) => b.blockId)).toEqual(blockIds); expect((row.metadata as { droppedIds: string[] }).droppedIds).toEqual(["FORGED_BLOCK_ID"]); // apply: only the real blocks are touched const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, { headers: h, body: { suggestionId: row.id }, }); expect(applyRes.status).toBe(200); const apply = (await json(applyRes)) as { results: Array<{ status: string }> }; expect(apply.results).toHaveLength(2); for (const r of apply.results) expect(r.status).toBe("applied"); const forged = await app.prisma.block.findUnique({ where: { id: "FORGED_BLOCK_ID" } }); expect(forged).toBeNull(); }); }); // --------------------------------------------------------------------------- // concurrent edits (the 409 rebase path) // --------------------------------------------------------------------------- describe("TASK-0123 writing — version conflicts (rebase)", () => { it("a concurrent edit bumps the version → apply fails that block with 409 + actualVersion, the rest apply", async () => { const { owner, workspaceId, h } = await freshWorkspace("Rebase Owner"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Race", [ "Racing paragraph one that someone else will edit between suggest and apply.", "Racing paragraph two that stays untouched so it can still be applied.", ]); const out = await streamWriting(owner.accessToken!, workspaceId, { action: "improve", pageId, blockIds }); const { row } = await suggestionFrom(out.events); const versionAtSuggest = blockIds.map((id) => app.prisma.block.findUniqueOrThrow({ where: { id }, select: { version: true } }), ); const v1 = (await Promise.all(versionAtSuggest))[0]!.version; // the "concurrent user" edits block #1 (no baseVersion → unconditional bump) const patchRes = await app.request("PATCH", `/api/v1/blocks/${blockIds[0]}`, { headers: h, body: { content: { text: "Concurrently edited by another user." } }, }); expect([200, 201]).toContain(patchRes.status); const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, { headers: h, body: { suggestionId: row.id }, }); expect(applyRes.status).toBe(200); const apply = (await json(applyRes)) as { results: Array<{ blockId: string; status: string; code?: string; actualVersion?: number }>; }; const failed = apply.results.find((r) => r.blockId === blockIds[0]!)!; expect(failed).toMatchObject({ status: "failed", code: "CONFLICT" }); expect(failed.actualVersion).toBe(v1 + 1); // failure isolation: block #2 applied fine expect(apply.results.find((r) => r.blockId === blockIds[1]!)!.status).toBe("applied"); // and the failed block kept the CONCURRENT user's text (not the AI's) const b1 = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[0]! } }); expect((b1.content as { text: string }).text).toBe("Concurrently edited by another user."); }); }); // --------------------------------------------------------------------------- // budget + no-smuggling // --------------------------------------------------------------------------- describe("TASK-0123 writing — budget + apply validation", () => { it("an exhausted budget → 402 AI_BUDGET_EXCEEDED (pre-stream, no suggestion persisted)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Budget Owner"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Budget", [ "Budget test paragraph with a sentence long enough for the mock to count.", ]); // 1-token budget → the first call's estimate blows it await app.prisma.workspace.update({ where: { id: workspaceId }, data: { usageLimits: { aiMonthlyTokens: 1 } }, }); resetWorkspaceCache(); // the guard reads the workspace through a 30 s cache const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, { headers: h, body: { action: "improve", pageId, blockIds }, }); expect(res.status).toBe(402); const body = await json(res); expect(body.error.code).toBe("AI_BUDGET_EXCEEDED"); // nothing persisted (the guard failed before the conversation was created) const convs = await app.prisma.aiConversation.findMany({ where: { workspaceId, title: "writing:improve" } }); expect(convs).toHaveLength(0); // restore await app.prisma.workspace.update({ where: { id: workspaceId }, data: { usageLimits: {} }, }); resetWorkspaceCache(); }); it("apply rejects smuggled blockIds (422 unknown_block_id) and accepts a SUBSET (accept-one)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Subset Owner"); const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Subset", [ "Subset paragraph one that will be accepted through the subset path.", "Subset paragraph two that the owner decides to keep as-is.", ]); const out = await streamWriting(owner.accessToken!, workspaceId, { action: "rewrite", pageId, blockIds }); const { row } = await suggestionFrom(out.events); // smuggling: an id outside the suggestion const smuggle = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, { headers: h, body: { suggestionId: row.id, blockIds: [blockIds[0]!, "smuggled-id"] }, }); expect(smuggle.status).toBe(422); const smuggleBody = await json(smuggle); const reason = (smuggleBody.error.details as Array<{ reason?: string }>)?.[0]?.reason; expect(reason).toBe("unknown_block_id"); // accept ONE: only the first block is applied const subset = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, { headers: h, body: { suggestionId: row.id, blockIds: [blockIds[0]!] }, }); expect(subset.status).toBe(200); const apply = (await json(subset)) as { results: Array<{ blockId: string; status: string }> }; expect(apply.results).toHaveLength(1); expect(apply.results[0]!.status).toBe("applied"); // the other block is untouched const b2 = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[1]! } }); expect((b2.content as { text: string }).text).toBe("Subset paragraph two that the owner decides to keep as-is."); }); });