/** * TASK-0126 — AI page-generation integration suite (the golden structure + * rollback + timeout + idempotency + permissions). * * Full stack (AppModule + Postgres + Redis) against the deterministic * `MockProvider` (`AI_PROVIDER=mock`; the `mode:"generate"` branch emits the * deterministic "product launch plan" plan: a root page, six HEADING_2 * sections, and a child "Tasks" database with a Due date column + 2 records). * * Covers the spec's integration acceptance criteria: * - golden (API-asserted structure): the SSE stream emits the per-step * `tool` frames + a terminal `done {pageId, created:{pages,blocks, * databases,relations}}`; the root page has the six HEADING_2 sections; the * Tasks child database has Title/Status/Due (Due is DATE) + the 2 sample * records (Due stored as `{start}`); the cost row carries * `metadata.feature="generate"`; the hidden `generate:` conversation never * appears in the list. * - rollback: the `mock-generate` seam arms a broken step → terminal in-band * `error {reason:"generation_failed"}`, the partial tree is rolled back * (no live generated page remains), audit `ai.generation_failed`. * - timeout: the seam arms a slow plan + the env cap is lowered to 2 s → * terminal `error {code:"SERVICE_UNAVAILABLE", reason:"ai_timeout"}` in * ≈2 s, nothing created, audit `failedStep:"timeout"`. * - idempotency: a retried request with the SAME `Idempotency-Key` replays * the terminal `done` (same pageId) without double-creating the page. * - permissions: a GUEST (VIEW) gets 403 pre-stream, a non-member gets 404 * (fail-closed) — both create nothing. */ // 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. process.env.AI_MOCK_DELTA_DELAY_MS = "10"; 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 budget guard reads workspaces // through a 30 s cache, so the direct-DB budget writes need the reset to take // effect immediately. The arming maps (mock-generate) are also cleared by the // reset, which keeps the per-workspace arming isolated. 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 GenEvent = { type: "tool" | "done" | "cancelled" | "error"; name?: string; status?: "started" | "done"; ref?: string; pageId?: string; created?: { pages: string[]; blocks: number; databases: string[]; relations: number }; tokensIn?: number; tokensOut?: number; code?: string; message?: string; reason?: string; retryable?: boolean; }; /** POST the generate endpoint (SSE), collect the frames. `signal` lets a test * abort a stuck stream (the timeout test relies on the server capping, not on * this). */ async function streamGenerate( path: string, accessToken: string, body: Record, opts: { idempotencyKey?: string } = {}, ): Promise<{ status: number; events: GenEvent[]; contentType: string; ms: number }> { const started = Date.now(); const res = await fetch(`${app.baseUrl}${path}`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}`, ...(opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {}), }, body: JSON.stringify(body), }); const contentType = res.headers.get("content-type") ?? ""; const events: GenEvent[] = []; if (res.body) { 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 GenEvent); } } } } return { status: res.status, events, contentType, ms: Date.now() - started }; } const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** 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!) }; } /** Arm the mock-generate seam for one workspace (single-shot). */ async function armGenerateSeam(workspaceId: string, body: { failStep?: number; slowMs?: number }): Promise { const res = await app.request("POST", `/internal/workspaces/${workspaceId}/ai/mock-generate`, { headers: { Authorization: `Bearer ${app.internalServiceToken}` }, body, }); expect(res.status).toBe(200); const body2 = (await json(res)) as { armed: boolean }; expect(body2.armed).toBe(true); } /** The persisted assistant cost row for a finished call. */ async function costRow(assistantMessageId: string) { return app.prisma.aiMessage.findUniqueOrThrow({ where: { id: assistantMessageId } }); } /** Assert none of the hidden per-call conversation titles appear in the list. */ async function assertHiddenConvsAbsent(token: string, workspaceId: string, titles: string[]): Promise { const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/ai/conversations`, { headers: authHeaders(token), }); expect(res.status).toBe(200); const list = (await json(res)) as { data: Array<{ title: string }> }; const shown = new Set(list.data.map((c) => c.title)); for (const t of titles) expect(shown.has(t), `hidden conversation "${t}" must not be listed`).toBe(false); } // --------------------------------------------------------------------------- // golden // --------------------------------------------------------------------------- describe("TASK-0126 generate — golden structure", () => { it("the product-launch prompt yields a page (6 headings) + a Tasks database (Due date + 2 records)", async () => { const { owner, workspaceId, h } = await freshWorkspace("Gen Owner"); const out = await streamGenerate(`/api/v1/workspaces/${workspaceId}/ai/generate`, owner.accessToken!, { prompt: "a product launch plan with a tasks database", }); expect(out.status).toBe(200); expect(out.contentType).toContain("text/event-stream"); // Per-step tool frames (createPage, insertBlocks, createDatabase) — each // started + done, in plan order. const toolNames = out.events.filter((e) => e.type === "tool").map((e) => e.name); expect(toolNames).toContain("createPage"); expect(toolNames).toContain("insertBlocks"); expect(toolNames).toContain("createDatabase"); // Exactly ONE terminal frame, of type `done`, with the created counts. const terminals = out.events.filter((e) => e.type === "done" || e.type === "error" || e.type === "cancelled"); expect(terminals).toHaveLength(1); const done = terminals[0] as GenEvent & { type: "done" }; expect(done.type).toBe("done"); expect(done.pageId).toBeTruthy(); expect(done.created?.pages).toHaveLength(1); expect(done.created?.blocks).toBe(6); expect(done.created?.databases).toHaveLength(1); expect(done.created?.relations).toBe(0); const pageId = done.pageId!; // The root page title + its six HEADING_2 sections (re-read via the API). const page = await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } }); expect(page.title).toBe("Product Launch Plan"); const blocks = await app.prisma.block.findMany({ where: { pageId, deletedAt: null } }); const headings = blocks.filter((b) => b.type === "HEADING_2").map((b) => (b.content as { text: string }).text); expect(headings).toEqual(["Objectives", "Requirements", "Timeline", "Risks", "Team", "Meeting Notes"]); // The Tasks child database: a child page (parentId=root) with a Database row. const childPage = await app.prisma.page.findFirst({ where: { parentId: pageId, workspaceId, deletedAt: null } }); expect(childPage).toBeTruthy(); expect(childPage!.title).toBe("Tasks"); const db = await app.prisma.database.findFirst({ where: { pageId: childPage!.id } }); expect(db).toBeTruthy(); // Properties: seeded Title + Status + the AI-added Due (DATE). const props = await app.prisma.databaseProperty.findMany({ where: { databaseId: db!.id, deletedAt: null } }); const byName = Object.fromEntries(props.map((p) => [p.name, p])); expect(byName.Title).toBeTruthy(); expect(byName.Status).toBeTruthy(); expect(byName.Due).toBeTruthy(); expect(byName.Due.type).toBe("DATE"); // The seeded Status select carries the To Do / In Progress / Done options. const statusOpts = (byName.Status.config as { options: Array<{ name: string }> }).options.map((o) => o.name); expect(statusOpts).toEqual(expect.arrayContaining(["To Do", "In Progress", "Done"])); // The 2 sample records, with the Due date stored as `{ start }`. A record // IS a page (title on the page); property values live in RecordProperty. const records = await app.prisma.databaseRecord.findMany({ where: { databaseId: db!.id } }); expect(records).toHaveLength(2); const dueId = byName.Due.id; const recordPages = await app.prisma.page.findMany({ where: { id: { in: records.map((r) => r.pageId).filter(Boolean) } } }); const titles = recordPages.map((p) => p.title).sort(); expect(titles).toEqual(["Draft launch announcement", "Schedule launch review"]); for (const r of records) { const dueRow = await app.prisma.recordProperty.findUnique({ where: { recordId_propertyId: { recordId: r.id, propertyId: dueId } }, }); expect(dueRow?.value).toMatchObject({ start: expect.stringMatching(/^2026-01-(15|20)$/) }); } // Audit + cost row (feature="generate"). const audit = await app.prisma.auditLog.findFirst({ where: { workspaceId, action: "ai.page_generated", resourceId: pageId }, }); expect(audit).toBeTruthy(); const hidden = await app.prisma.aiConversation.findFirst({ where: { workspaceId, title: "generate:" } }); expect(hidden).toBeTruthy(); const row = await app.prisma.aiMessage.findFirst({ where: { conversationId: hidden!.id, role: "assistant" }, }); expect(row).toBeTruthy(); expect((row!.metadata as { feature: string }).feature).toBe("generate"); expect(row!.tokensIn!).toBeGreaterThan(0); // The hidden per-call conversation is not listed in the panel. await assertHiddenConvsAbsent(owner.accessToken!, workspaceId, ["generate:"]); void h; }); }); // --------------------------------------------------------------------------- // rollback // --------------------------------------------------------------------------- describe("TASK-0126 generate — rollback on failure", () => { it("a failing step rolls back the partial tree (no live generated page, audit generation_failed)", async () => { const { owner, workspaceId } = await freshWorkspace("GenRoll Owner"); // failStep:2 → the plan's 2nd step (insertBlocks) is broken (unknown ref). await armGenerateSeam(workspaceId, { failStep: 2 }); const out = await streamGenerate(`/api/v1/workspaces/${workspaceId}/ai/generate`, owner.accessToken!, { prompt: "a product launch plan", }); expect(out.status).toBe(200); // the error is in-band (SSE already flushed) // Exactly ONE terminal frame: the in-band error (not `done`). const terminals = out.events.filter((e) => e.type === "done" || e.type === "error" || e.type === "cancelled"); expect(terminals).toHaveLength(1); const err = terminals[0] as GenEvent; expect(err.type).toBe("error"); expect(err.reason).toBe("generation_failed"); expect(err.retryable).toBe(true); // The partial tree (the root page) was rolled back — no live page remains. const leftover = await app.prisma.page.findMany({ where: { workspaceId, title: "Product Launch Plan", trashedAt: null }, }); expect(leftover).toHaveLength(0); // Audit records the failure with the failed step. const audit = await app.prisma.auditLog.findFirst({ where: { workspaceId, action: "ai.generation_failed" }, }); expect(audit).toBeTruthy(); expect((audit!.metadata as { failedStep: string }).failedStep).toBe("insertBlocks"); }); }); // --------------------------------------------------------------------------- // timeout // --------------------------------------------------------------------------- describe("TASK-0126 generate — total timeout", () => { it("a slow plan past the cap → SERVICE_UNAVAILABLE ai_timeout in ≈2 s, nothing created", async () => { const { owner, workspaceId } = await freshWorkspace("GenTimeout Owner"); // The seam makes the plan fetch take 5 s; the env cap is lowered to 2 s so // the watchdog fires first (the service reads the cap per call). await armGenerateSeam(workspaceId, { slowMs: 5000 }); process.env.AI_GENERATE_TIMEOUT_MS = "2000"; try { const out = await streamGenerate(`/api/v1/workspaces/${workspaceId}/ai/generate`, owner.accessToken!, { prompt: "a product launch plan", }); expect(out.status).toBe(200); const terminals = out.events.filter((e) => e.type === "done" || e.type === "error" || e.type === "cancelled"); expect(terminals).toHaveLength(1); const err = terminals[0] as GenEvent; expect(err.type).toBe("error"); expect(err.code).toBe("SERVICE_UNAVAILABLE"); expect(err.reason).toBe("ai_timeout"); expect(err.retryable).toBe(true); // Capped at ≈2 s (the watchdog), NOT the mock's 5 s plan latency. expect(out.ms).toBeLessThan(4000); expect(out.ms).toBeGreaterThanOrEqual(1900); // Nothing was created (the plan never reached the executor). const leftover = await app.prisma.page.findMany({ where: { workspaceId, title: "Product Launch Plan" }, }); expect(leftover).toHaveLength(0); const audit = await app.prisma.auditLog.findFirst({ where: { workspaceId, action: "ai.generation_failed" }, }); expect(audit).toBeTruthy(); expect((audit!.metadata as { failedStep: string }).failedStep).toBe("timeout"); } finally { delete process.env.AI_GENERATE_TIMEOUT_MS; } }); }); // --------------------------------------------------------------------------- // idempotency // --------------------------------------------------------------------------- describe("TASK-0126 generate — idempotency (Idempotency-Key)", () => { it("a retry with the SAME key replays the terminal `done` (same pageId) without double-creating", async () => { const { owner, workspaceId } = await freshWorkspace("GenIdem Owner"); const key = `gen_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const path = `/api/v1/workspaces/${workspaceId}/ai/generate`; const first = await streamGenerate(path, owner.accessToken!, { prompt: "a product launch plan" }, { idempotencyKey: key }); const done1 = first.events.find((e) => e.type === "done") as GenEvent; expect(done1).toBeTruthy(); expect(done1.pageId).toBeTruthy(); const pagesAfterFirst = await app.prisma.page.count({ where: { workspaceId, title: "Product Launch Plan", trashedAt: null } }); expect(pagesAfterFirst).toBe(1); // The retry: SAME key → the service replays the terminal `done` (the same // pageId) WITHOUT re-running the executor (no second page). const second = await streamGenerate(path, owner.accessToken!, { prompt: "a product launch plan" }, { idempotencyKey: key }); const done2 = second.events.find((e) => e.type === "done") as GenEvent; expect(done2).toBeTruthy(); expect(done2.pageId).toBe(done1.pageId); const pagesAfterRetry = await app.prisma.page.count({ where: { workspaceId, title: "Product Launch Plan", trashedAt: null } }); expect(pagesAfterRetry).toBe(1); // no double-create }); }); // --------------------------------------------------------------------------- // permissions // --------------------------------------------------------------------------- describe("TASK-0126 generate — permissions", () => { it("a GUEST (VIEW) gets 403 pre-stream and nothing is created", async () => { const { workspaceId } = await freshWorkspace("GenPerm Owner"); const guest = await createUser(app.request, { name: "Gen Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/generate`, { headers: authHeaders(guest.accessToken!), body: { prompt: "a product launch plan" }, }); expect(res.status).toBe(403); const body = await json(res); expect(body.error.code).toBe("FORBIDDEN"); const leftover = await app.prisma.page.count({ where: { workspaceId, title: "Product Launch Plan" } }); expect(leftover).toBe(0); const convs = await app.prisma.aiConversation.findMany({ where: { workspaceId, title: "generate:" } }); expect(convs).toHaveLength(0); }); it("a non-member gets 404 (fail-closed) and nothing is created", async () => { const { workspaceId } = await freshWorkspace("GenNonMember Owner"); const stranger = await createUser(app.request, { name: "Gen Stranger" }); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/generate`, { headers: authHeaders(stranger.accessToken!), body: { prompt: "a product launch plan" }, }); expect(res.status).toBe(404); const body = await json(res); expect(body.error.code).toBe("NOT_FOUND"); const leftover = await app.prisma.page.count({ where: { workspaceId, title: "Product Launch Plan" } }); expect(leftover).toBe(0); }); });