/** * TASK-0126 — unit tests for the generation EXECUTOR's permission-context * hook + ref resolution (the two behaviors the spec calls out by name). * * Covers: * - the PER-STEP permission-context assertion: an ELEVATED context * (`ctx.userId !== expectedUserId`) throws 403 `elevated_context` BEFORE * any domain service is called — zero side effects; * - the `assertContext` hook: it fires with the REQUESTER's context before * every domain call (the seam a hostile test/bogus injection would use to * trip the assertion); * - ref resolution: the plan's `ref`/`pageRef`/`databaseRef` aliases resolve * to the real ids the executor created (a `createDatabase` lands under the * referenced page; the property name→id lookup feeds the sample records); * - ROLLBACK: a failing step (a block the domain rejects) rolls the partial * tree back in reverse — the created page is trashed — and the failure is * surfaced as `GenerationStepFailed` naming the failed step. */ import { describe, expect, it, vi } from "vitest"; import { GenerationAborted, GenerationExecutor, GenerationStepFailed, type ExecutorServices, type ExecutorContext, } from "../generator/executor.js"; import { ApiError } from "../../../common/errors.js"; import type { GeneratePlan } from "@factory/ai"; /** A fake `req` (the executor only passes it through to the domain services). */ const req = {} as ExecutorContext["req"]; /** Build a mocked domain-services bundle (every method a `vi.fn`). The * property name→id lookup (`prisma.databaseProperty.findMany`) reflects the * properties actually created through `databases.createProperty`. */ function mockServices(overrides: Partial = {}): ExecutorServices { const propRows: { id: string; name: string }[] = []; const pages: any = { create: vi.fn(async (_ws: string, _user: string, input: any) => ({ id: `page_${pages.create.mock.calls.length}`, // A database page returns its databaseId too. ...(input.type === "database" ? { databaseId: `db_${pages.create.mock.calls.length}` } : {}), })), trash: vi.fn(async () => ({ deleted: true })), }; const blocks: any = { create: vi.fn(async (_ws: string, _user: string, input: any) => ({ id: `block_${blocks.create.mock.calls.length}` })), }; const databases: any = { createProperty: vi.fn(async (_ws: string, _user: string, input: any) => { const row = { id: `prop_${propRows.length + 1}`, name: input.name }; propRows.push(row); return row; }), createRelation: vi.fn(async () => ({ id: `rel_1` })), deleteRelation: vi.fn(async () => ({ deleted: true })), remove: vi.fn(async () => ({ deleted: true })), }; const records: any = { createRecord: vi.fn(async () => ({ id: `rec_${records.createRecord.mock.calls.length}` })), }; const prisma: any = { databaseProperty: { findMany: vi.fn(async () => propRows.map((r) => ({ ...r }))), }, }; return { pages, blocks, databases, records, prisma, ...overrides } as ExecutorServices; } /** A base requester context (the happy path). */ function baseCtx(overrides: Partial = {}): ExecutorContext { return { workspaceId: "ws_1", userId: "user_requester", req, signal: new AbortController().signal, onStep: () => {}, ...overrides, }; } describe("TASK-0126 executor — permission-context hook", () => { it("an elevated context (userId != expectedUserId) throws 403 elevated_context BEFORE any domain call", async () => { const svc = mockServices(); const ctx = baseCtx({ expectedUserId: "user_requester", // the REQUESTER // The executor's own context is ELEVATED (a test seam / bug): userId: "user_elevated", }); const plan: GeneratePlan = { tools: [{ name: "createPage", args: { title: "Should not run" } }], }; const executor = new GenerationExecutor(svc, ctx); await expect(executor.execute(plan)).rejects.toBeInstanceOf(GenerationStepFailed); // NO domain call was made (the hook fires before the first write). expect(svc.pages.create).not.toHaveBeenCalled(); expect(svc.blocks.create).not.toHaveBeenCalled(); expect(svc.databases.createProperty).not.toHaveBeenCalled(); }); it("the assertContext hook fires with the REQUESTER context before every domain call", async () => { const seen: string[] = []; const svc = mockServices(); const ctx = baseCtx({ assertContext: (c) => { seen.push(c.userId); if (c.userId !== "user_requester") throw new Error("elevated"); }, }); const plan: GeneratePlan = { tools: [ { name: "createPage", args: { title: "Root", blocks: [{ type: "PARAGRAPH", content: { text: "x" } }] } }, ], }; const executor = new GenerationExecutor(svc, ctx); await executor.execute(plan); // createPage (1) + the inline block's per-block hook (1) = 2 assertions. expect(seen).toEqual(["user_requester", "user_requester"]); }); it("a matching (non-elevated) context passes the hook and creates the page", async () => { const svc = mockServices(); const ctx = baseCtx({ expectedUserId: "user_requester" }); const plan: GeneratePlan = { tools: [{ name: "createPage", args: { title: "OK", blocks: [{ type: "PARAGRAPH", content: { text: "hi" } }] } }], }; const executor = new GenerationExecutor(svc, ctx); const result = await executor.execute(plan); expect(result.created.pages).toHaveLength(1); expect(result.created.blocks).toBe(1); }); }); describe("TASK-0126 executor — ref resolution", () => { it("a createDatabase resolves pageRef → the referenced page, and maps property name→id for sample records", async () => { const svc = mockServices(); const ctx = baseCtx(); const plan: GeneratePlan = { tools: [ { name: "createPage", args: { ref: "root", title: "Root" } }, { name: "createDatabase", args: { ref: "tasks", pageRef: "root", title: "Tasks", properties: [{ name: "Due", type: "date" }], sampleRecords: [{ properties: { Title: "First task", Due: "2026-01-15" } }], }, }, ], }; const executor = new GenerationExecutor(svc, ctx); const result = await executor.execute(plan); // The database page is created UNDER the referenced root page. const dbCreate = vi.mocked(svc.pages.create).mock.calls.find((c: any[]) => c[2].type === "database"); expect(dbCreate).toBeTruthy(); expect(dbCreate![2].parentId).toBe(result.created.pages[0]); // root page id // One property created, one record with the property name→id-mapped value. expect(svc.databases.createProperty).toHaveBeenCalledTimes(1); expect(svc.records.createRecord).toHaveBeenCalledTimes(1); const recCall = vi.mocked(svc.records.createRecord).mock.calls[0] as any[]; expect(recCall[2].title).toBe("First task"); // The Due value is mapped to the property id. expect(Object.keys(recCall[2].properties)).toHaveLength(1); expect(recCall[2].properties[Object.keys(recCall[2].properties)[0]]).toBe("2026-01-15"); }); it("a createRelation resolves both database refs and creates a two-way relation", async () => { const svc = mockServices(); const ctx = baseCtx(); const plan: GeneratePlan = { tools: [ { name: "createPage", args: { ref: "root", title: "Root" } }, { name: "createDatabase", args: { ref: "a", pageRef: "root", title: "A", properties: [{ name: "A1", type: "text" }] } }, { name: "createDatabase", args: { ref: "b", pageRef: "root", title: "B", properties: [{ name: "B1", type: "text" }] } }, { name: "createRelation", args: { databaseRef: "a", targetPageRef: "b", name: "Links" } }, ], }; const executor = new GenerationExecutor(svc, ctx); const result = await executor.execute(plan); expect(result.created.databases).toHaveLength(2); expect(result.created.relations).toBe(1); expect(svc.databases.createRelation).toHaveBeenCalledTimes(1); }); }); describe("TASK-0126 executor — first-page semantics (recorded decision)", () => { it("an omitted pageRef targets the FIRST page even when it carries a NAMED ref", async () => { const svc = mockServices(); const ctx = baseCtx(); // The model's real shape (observed 2025, qwen3.8-27B): it names the first // page (ref "plan") and then appends body blocks WITHOUT a pageRef. Before // the fix this failed: "Plan references page (root) before it is created" // (the ref map had no `__root__` entry because the page was registered // under its named ref). const plan: GeneratePlan = { tools: [ { name: "createPage", args: { ref: "plan", title: "Root" } }, { name: "insertBlocks", args: { blocks: [{ type: "PARAGRAPH", content: { text: "body" } }] } }, ], }; const executor = new GenerationExecutor(svc, ctx); const result = await executor.execute(plan); // The block landed on the first (named) page — not a phantom `__root__`. expect(svc.blocks.create).toHaveBeenCalledTimes(1); expect((vi.mocked(svc.blocks.create).mock.calls[0] as any[])[0]).toBe(result.created.pages[0]); expect(result.created.blocks).toBe(1); }); it("an omitted pageRef BEFORE any createPage fails with no_host_page (no page exists yet)", async () => { const svc = mockServices(); const ctx = baseCtx(); const plan: GeneratePlan = { tools: [{ name: "insertBlocks", args: { blocks: [{ type: "PARAGRAPH", content: {} }] } }], }; const executor = new GenerationExecutor(svc, ctx); await expect(executor.execute(plan)).rejects.toBeInstanceOf(GenerationStepFailed); expect(svc.blocks.create).not.toHaveBeenCalled(); expect(svc.pages.create).not.toHaveBeenCalled(); }); }); describe("TASK-0126 executor — rollback on failure", () => { it("a failing step (unknown block type) rolls the partial tree back and names the failed step", async () => { const svc = mockServices(); const ctx = baseCtx(); const plan: GeneratePlan = { tools: [ { name: "createPage", args: { title: "Root", blocks: [{ type: "BOGUS_TYPE", content: {} }] } }, ], }; const executor = new GenerationExecutor(svc, ctx); const err: unknown = await executor.execute(plan).catch((e) => e); expect(err).toBeInstanceOf(GenerationStepFailed); expect((err as GenerationStepFailed).failedStep).toBe("createPage"); // The created page was trashed (best-effort rollback, reverse order). expect(svc.pages.trash).toHaveBeenCalledTimes(1); }); it("an aborted signal mid-run throws GenerationAborted and rolls back", async () => { const ac = new AbortController(); const svc = mockServices(); // Abort immediately → the first step's checkAborted trips. ac.abort("cancelled"); const ctx = baseCtx({ signal: ac.signal }); const plan: GeneratePlan = { tools: [ { name: "createPage", args: { title: "Never" } }, ], }; const executor = new GenerationExecutor(svc, ctx); await expect(executor.execute(plan)).rejects.toBeInstanceOf(GenerationAborted); expect(svc.pages.create).not.toHaveBeenCalled(); }); }); describe("TASK-0126 executor — ref validation", () => { it("a plan referencing an unknown ref fails with an UNPROCESSABLE error (no silent no-op)", async () => { const svc = mockServices(); const ctx = baseCtx(); // insertBlocks before any page → the root ref is unresolved. const plan: GeneratePlan = { tools: [{ name: "insertBlocks", args: { pageRef: "nope", blocks: [{ type: "PARAGRAPH", content: {} }] } }], }; const executor = new GenerationExecutor(svc, ctx); await expect(executor.execute(plan)).rejects.toBeInstanceOf(GenerationStepFailed); expect(svc.blocks.create).not.toHaveBeenCalled(); }); });