1
19import { describe, expect, it, vi } from "vitest";
20
21import {
22 GenerationAborted,
23 GenerationExecutor,
24 GenerationStepFailed,
25 type ExecutorServices,
26 type ExecutorContext,
27} from "../generator/executor.js";
28import { ApiError } from "../../../common/errors.js";
29import type { GeneratePlan } from "@factory/ai";
30
31
32const req = {} as ExecutorContext["req"];
33
34
37function mockServices(overrides: Partial<ExecutorServices> = {}): ExecutorServices {
38 const propRows: { id: string; name: string }[] = [];
39 const pages: any = {
40 create: vi.fn(async (_ws: string, _user: string, input: any) => ({
41 id: `page_${pages.create.mock.calls.length}`,
42
43 ...(input.type === "database" ? { databaseId: `db_${pages.create.mock.calls.length}` } : {}),
44 })),
45 trash: vi.fn(async () => ({ deleted: true })),
46 };
47 const blocks: any = {
48 create: vi.fn(async (_ws: string, _user: string, input: any) => ({ id: `block_${blocks.create.mock.calls.length}` })),
49 };
50 const databases: any = {
51 createProperty: vi.fn(async (_ws: string, _user: string, input: any) => {
52 const row = { id: `prop_${propRows.length + 1}`, name: input.name };
53 propRows.push(row);
54 return row;
55 }),
56 createRelation: vi.fn(async () => ({ id: `rel_1` })),
57 deleteRelation: vi.fn(async () => ({ deleted: true })),
58 remove: vi.fn(async () => ({ deleted: true })),
59 };
60 const records: any = {
61 createRecord: vi.fn(async () => ({ id: `rec_${records.createRecord.mock.calls.length}` })),
62 };
63 const prisma: any = {
64 databaseProperty: {
65 findMany: vi.fn(async () => propRows.map((r) => ({ ...r }))),
66 },
67 };
68 return { pages, blocks, databases, records, prisma, ...overrides } as ExecutorServices;
69}
70
71
72function baseCtx(overrides: Partial<ExecutorContext> = {}): ExecutorContext {
73 return {
74 workspaceId: "ws_1",
75 userId: "user_requester",
76 req,
77 signal: new AbortController().signal,
78 onStep: () => {},
79 ...overrides,
80 };
81}
82
83describe("TASK-0126 executor — permission-context hook", () => {
84 it("an elevated context (userId != expectedUserId) throws 403 elevated_context BEFORE any domain call", async () => {
85 const svc = mockServices();
86 const ctx = baseCtx({
87 expectedUserId: "user_requester",
88
89 userId: "user_elevated",
90 });
91 const plan: GeneratePlan = {
92 tools: [{ name: "createPage", args: { title: "Should not run" } }],
93 };
94 const executor = new GenerationExecutor(svc, ctx);
95 await expect(executor.execute(plan)).rejects.toBeInstanceOf(GenerationStepFailed);
96
97 expect(svc.pages.create).not.toHaveBeenCalled();
98 expect(svc.blocks.create).not.toHaveBeenCalled();
99 expect(svc.databases.createProperty).not.toHaveBeenCalled();
100 });
101
102 it("the assertContext hook fires with the REQUESTER context before every domain call", async () => {
103 const seen: string[] = [];
104 const svc = mockServices();
105 const ctx = baseCtx({
106 assertContext: (c) => {
107 seen.push(c.userId);
108 if (c.userId !== "user_requester") throw new Error("elevated");
109 },
110 });
111 const plan: GeneratePlan = {
112 tools: [
113 { name: "createPage", args: { title: "Root", blocks: [{ type: "PARAGRAPH", content: { text: "x" } }] } },
114 ],
115 };
116 const executor = new GenerationExecutor(svc, ctx);
117 await executor.execute(plan);
118
119 expect(seen).toEqual(["user_requester", "user_requester"]);
120 });
121
122 it("a matching (non-elevated) context passes the hook and creates the page", async () => {
123 const svc = mockServices();
124 const ctx = baseCtx({ expectedUserId: "user_requester" });
125 const plan: GeneratePlan = {
126 tools: [{ name: "createPage", args: { title: "OK", blocks: [{ type: "PARAGRAPH", content: { text: "hi" } }] } }],
127 };
128 const executor = new GenerationExecutor(svc, ctx);
129 const result = await executor.execute(plan);
130 expect(result.created.pages).toHaveLength(1);
131 expect(result.created.blocks).toBe(1);
132 });
133});
134
135describe("TASK-0126 executor — ref resolution", () => {
136 it("a createDatabase resolves pageRef → the referenced page, and maps property name→id for sample records", async () => {
137 const svc = mockServices();
138 const ctx = baseCtx();
139 const plan: GeneratePlan = {
140 tools: [
141 { name: "createPage", args: { ref: "root", title: "Root" } },
142 {
143 name: "createDatabase",
144 args: {
145 ref: "tasks",
146 pageRef: "root",
147 title: "Tasks",
148 properties: [{ name: "Due", type: "date" }],
149 sampleRecords: [{ properties: { Title: "First task", Due: "2026-01-15" } }],
150 },
151 },
152 ],
153 };
154 const executor = new GenerationExecutor(svc, ctx);
155 const result = await executor.execute(plan);
156
157
158 const dbCreate = vi.mocked(svc.pages.create).mock.calls.find((c: any[]) => c[2].type === "database");
159 expect(dbCreate).toBeTruthy();
160 expect(dbCreate![2].parentId).toBe(result.created.pages[0]);
161
162
163 expect(svc.databases.createProperty).toHaveBeenCalledTimes(1);
164 expect(svc.records.createRecord).toHaveBeenCalledTimes(1);
165 const recCall = vi.mocked(svc.records.createRecord).mock.calls[0] as any[];
166 expect(recCall[2].title).toBe("First task");
167
168 expect(Object.keys(recCall[2].properties)).toHaveLength(1);
169 expect(recCall[2].properties[Object.keys(recCall[2].properties)[0]]).toBe("2026-01-15");
170 });
171
172 it("a createRelation resolves both database refs and creates a two-way relation", async () => {
173 const svc = mockServices();
174 const ctx = baseCtx();
175 const plan: GeneratePlan = {
176 tools: [
177 { name: "createPage", args: { ref: "root", title: "Root" } },
178 { name: "createDatabase", args: { ref: "a", pageRef: "root", title: "A", properties: [{ name: "A1", type: "text" }] } },
179 { name: "createDatabase", args: { ref: "b", pageRef: "root", title: "B", properties: [{ name: "B1", type: "text" }] } },
180 { name: "createRelation", args: { databaseRef: "a", targetPageRef: "b", name: "Links" } },
181 ],
182 };
183 const executor = new GenerationExecutor(svc, ctx);
184 const result = await executor.execute(plan);
185 expect(result.created.databases).toHaveLength(2);
186 expect(result.created.relations).toBe(1);
187 expect(svc.databases.createRelation).toHaveBeenCalledTimes(1);
188 });
189});
190
191describe("TASK-0126 executor — first-page semantics (recorded decision)", () => {
192 it("an omitted pageRef targets the FIRST page even when it carries a NAMED ref", async () => {
193 const svc = mockServices();
194 const ctx = baseCtx();
195
196
197
198
199
200 const plan: GeneratePlan = {
201 tools: [
202 { name: "createPage", args: { ref: "plan", title: "Root" } },
203 { name: "insertBlocks", args: { blocks: [{ type: "PARAGRAPH", content: { text: "body" } }] } },
204 ],
205 };
206 const executor = new GenerationExecutor(svc, ctx);
207 const result = await executor.execute(plan);
208
209 expect(svc.blocks.create).toHaveBeenCalledTimes(1);
210 expect((vi.mocked(svc.blocks.create).mock.calls[0] as any[])[0]).toBe(result.created.pages[0]);
211 expect(result.created.blocks).toBe(1);
212 });
213
214 it("an omitted pageRef BEFORE any createPage fails with no_host_page (no page exists yet)", async () => {
215 const svc = mockServices();
216 const ctx = baseCtx();
217 const plan: GeneratePlan = {
218 tools: [{ name: "insertBlocks", args: { blocks: [{ type: "PARAGRAPH", content: {} }] } }],
219 };
220 const executor = new GenerationExecutor(svc, ctx);
221 await expect(executor.execute(plan)).rejects.toBeInstanceOf(GenerationStepFailed);
222 expect(svc.blocks.create).not.toHaveBeenCalled();
223 expect(svc.pages.create).not.toHaveBeenCalled();
224 });
225});
226
227describe("TASK-0126 executor — rollback on failure", () => {
228 it("a failing step (unknown block type) rolls the partial tree back and names the failed step", async () => {
229 const svc = mockServices();
230 const ctx = baseCtx();
231 const plan: GeneratePlan = {
232 tools: [
233 { name: "createPage", args: { title: "Root", blocks: [{ type: "BOGUS_TYPE", content: {} }] } },
234 ],
235 };
236 const executor = new GenerationExecutor(svc, ctx);
237 const err: unknown = await executor.execute(plan).catch((e) => e);
238 expect(err).toBeInstanceOf(GenerationStepFailed);
239 expect((err as GenerationStepFailed).failedStep).toBe("createPage");
240
241 expect(svc.pages.trash).toHaveBeenCalledTimes(1);
242 });
243
244 it("an aborted signal mid-run throws GenerationAborted and rolls back", async () => {
245 const ac = new AbortController();
246 const svc = mockServices();
247
248 ac.abort("cancelled");
249 const ctx = baseCtx({ signal: ac.signal });
250 const plan: GeneratePlan = {
251 tools: [
252 { name: "createPage", args: { title: "Never" } },
253 ],
254 };
255 const executor = new GenerationExecutor(svc, ctx);
256 await expect(executor.execute(plan)).rejects.toBeInstanceOf(GenerationAborted);
257 expect(svc.pages.create).not.toHaveBeenCalled();
258 });
259});
260
261describe("TASK-0126 executor — ref validation", () => {
262 it("a plan referencing an unknown ref fails with an UNPROCESSABLE error (no silent no-op)", async () => {
263 const svc = mockServices();
264 const ctx = baseCtx();
265
266 const plan: GeneratePlan = {
267 tools: [{ name: "insertBlocks", args: { pageRef: "nope", blocks: [{ type: "PARAGRAPH", content: {} }] } }],
268 };
269 const executor = new GenerationExecutor(svc, ctx);
270 await expect(executor.execute(plan)).rejects.toBeInstanceOf(GenerationStepFailed);
271 expect(svc.blocks.create).not.toHaveBeenCalled();
272 });
273});
274