1
14import { describe, expect, it, vi } from "vitest";
15import { FatalError, RetryableError, classifyError } from "../executors/classify.js";
16import {
17 emitDomainEvent,
18 interpolateConfig,
19 interpolateDeep,
20 resolveRecordId,
21 resolveUserId,
22 runWithTimeout,
23 buildVars,
24 type PropertyMetaLite
25} from "../executors/helpers.js";
26import { effectiveLevel, assertAutomationCanEdit } from "../executors/permission.js";
27import { reserveEmailSlot } from "../executors/email-cap.js";
28import { executeAction, executorFor, validateAction } from "../executors/registry.js";
29import { writeRecordProperties } from "../executors/record-write.js";
30import { AUTOMATION_EMAIL_DAILY_CAP, AUTOMATION_MAX_DEPTH, emailCapKey } from "@factory/shared";
31import type { AutomationRunContext, ExecutorDeps } from "../executors/types.js";
32
33
34
35
36
37
38function expectThrowsCode(fn: () => unknown, code: string): void {
39 let err: unknown;
40 try {
41 fn();
42 } catch (e) {
43 err = e;
44 }
45 expect(err, "expected the function to throw").toBeDefined();
46 expect((err as { code?: string }).code).toBe(code);
47}
48
49
50async function expectRejectsCode(fn: () => Promise<unknown>, code: string): Promise<void> {
51 let err: unknown;
52 try {
53 await fn();
54 } catch (e) {
55 err = e;
56 }
57 expect(err, "expected the promise to reject").toBeDefined();
58 expect((err as { code?: string }).code).toBe(code);
59}
60
61const ctx = (over: Partial<AutomationRunContext> = {}): AutomationRunContext => ({
62 runId: "run_1",
63 automationId: "auto_1",
64 automationName: "Test automation",
65 workspaceId: "ws_1",
66 databaseId: "db_1",
67 recordId: "rec_1",
68 triggerEvent: { type: "record.updated", payload: {} },
69 depth: 0,
70 input: null,
71 actorUserId: "user_creator",
72 vars: { record: null, event: {}, input: null, automation: { id: "auto_1", name: "Test automation" }, run: { id: "run_1" } },
73 ...over
74});
75
76const deps = (over: Partial<ExecutorDeps> = {}): ExecutorDeps => ({
77 prisma: {
78 databaseRecord: { findUnique: vi.fn().mockResolvedValue(null) },
79 databaseProperty: { findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]) },
80 recordProperty: { upsert: vi.fn().mockResolvedValue({}) },
81 page: { create: vi.fn(), findUnique: vi.fn().mockResolvedValue(null) },
82 database: { findUnique: vi.fn().mockResolvedValue(null) },
83 user: { findUnique: vi.fn().mockResolvedValue(null) },
84 workspaceMember: { findUnique: vi.fn().mockResolvedValue(null) },
85 teamMember: { findMany: vi.fn().mockResolvedValue([]) },
86 permission: { findMany: vi.fn().mockResolvedValue([]) },
87 notification: { create: vi.fn().mockResolvedValue({ id: "ntf_1" }) },
88 integration: { findFirst: vi.fn().mockResolvedValue(null) },
89 auditLog: { create: vi.fn().mockResolvedValue({}) },
90 $transaction: vi.fn()
91 } as unknown as ExecutorDeps["prisma"],
92 redis: {
93 xadd: vi.fn().mockResolvedValue("1-1"),
94 incr: vi.fn().mockResolvedValue(1),
95 expire: vi.fn().mockResolvedValue(1)
96 } as unknown as ExecutorDeps["redis"],
97 env: {} as ExecutorDeps["env"],
98 ...over
99});
100
101
102
103
104
105describe("classifyError", () => {
106 it("FatalError → not retryable, its code", () => {
107 expect(classifyError(new FatalError("ACTION_TIMEOUT", "x"))).toEqual({ retryable: false, code: "ACTION_TIMEOUT" });
108 });
109 it("RetryableError → retryable, its code", () => {
110 expect(classifyError(new RetryableError("NET_ECONNRESET", "x"))).toEqual({ retryable: true, code: "NET_ECONNRESET" });
111 });
112 it("Postgres transient SQLSTATE (40P01 deadlock) → retryable", () => {
113 const e = new Error("deadlock");
114 (e as unknown as { code: string }).code = "40P01";
115 expect(classifyError(e).retryable).toBe(true);
116 });
117 it("Postgres serialization (40001) → retryable", () => {
118 const e = new Error("serialization");
119 (e as unknown as { code: string }).code = "40001";
120 expect(classifyError(e).retryable).toBe(true);
121 });
122 it("network errno ECONNRESET → retryable", () => {
123 const e = new Error("reset");
124 (e as unknown as { errno: string }).errno = "ECONNRESET";
125 expect(classifyError(e)).toEqual({ retryable: true, code: "NET_ECONNRESET" });
126 });
127 it("unknown error → fatal (fail closed)", () => {
128 expect(classifyError(new Error("boom"))).toEqual({ retryable: false, code: "UNEXPECTED" });
129 });
130 it("TimeoutError → fatal ACTION_TIMEOUT", () => {
131 const e = new Error("timeout");
132 e.name = "TimeoutError";
133 expect(classifyError(e)).toEqual({ retryable: false, code: "ACTION_TIMEOUT" });
134 });
135});
136
137
138
139
140
141describe("resolveRecordId", () => {
142 it("from_trigger → ctx.recordId", () => {
143 expect(resolveRecordId({ recordId: "from_trigger" }, ctx())).toBe("rec_1");
144 });
145 it("literal id passthrough", () => {
146 expect(resolveRecordId({ recordId: "rec_9" }, ctx())).toBe("rec_9");
147 });
148 it("from_trigger with no trigger record → NO_TRIGGER_RECORD", () => {
149 expectThrowsCode(() => resolveRecordId({ recordId: "from_trigger" }, ctx({ recordId: null })), "NO_TRIGGER_RECORD");
150 });
151});
152
153describe("resolveUserId", () => {
154 it("literal cuid passthrough", () => {
155 expect(resolveUserId("user_x", ctx())).toBe("user_x");
156 });
157 it("automation_creator → ctx.actorUserId", () => {
158 expect(resolveUserId("automation_creator", ctx())).toBe("user_creator");
159 });
160 it("trigger_user → event payload createdBy", () => {
161 const c = ctx({ triggerEvent: { type: "record.created", payload: {}, createdBy: "user_event" } });
162 expect(resolveUserId("trigger_user", c)).toBe("user_event");
163 });
164 it("unresolvable → NO_TARGET_USER", () => {
165 expectThrowsCode(() => resolveUserId("trigger_user", ctx()), "NO_TARGET_USER");
166 });
167});
168
169
170
171
172
173describe("interpolateConfig", () => {
174 const vars = ctx().vars;
175 it("replaces known keys", () => {
176 expect(interpolateConfig("hi {{automation.name}}", vars, 200, "s", [])).toBe("hi Test automation");
177 });
178 it("unknown key → '' + warning", () => {
179 const w: string[] = [];
180 expect(interpolateConfig("{{nope.x}}", vars, 200, "s", w)).toBe("");
181 expect(w.length).toBeGreaterThan(0);
182 });
183 it("overflow → INTERPOLATION_OVERFLOW (fatal)", () => {
184 expectThrowsCode(() => interpolateConfig("x".repeat(300), vars, 200, "s", []), "INTERPOLATION_OVERFLOW");
185 });
186});
187
188describe("interpolateDeep", () => {
189 const vars = ctx().vars;
190 it("interpolates strings, passes through numbers/booleans/null", () => {
191 const out = interpolateDeep(
192 { a: "{{automation.id}}", b: 5, c: true, d: null, e: ["{{automation.id}}", 7] },
193 vars,
194 []
195 ) as Record<string, unknown>;
196 expect(out.a).toBe("auto_1");
197 expect(out.b).toBe(5);
198 expect(out.c).toBe(true);
199 expect(out.d).toBeNull();
200 expect(out.e).toEqual(["auto_1", 7]);
201 });
202});
203
204describe("runWithTimeout", () => {
205 it("resolves when the task finishes in time", async () => {
206 await expect(runWithTimeout(1000, () => Promise.resolve(42))).resolves.toBe(42);
207 });
208 it("timeout → fatal ACTION_TIMEOUT", async () => {
209
210
211
212
213 vi.useFakeTimers();
214 try {
215 const settled = runWithTimeout(100, () => new Promise(() => {})).catch((e) => (e as { code?: string }).code);
216 await vi.advanceTimersByTimeAsync(150);
217 await expect(settled).resolves.toBe("ACTION_TIMEOUT");
218 } finally {
219 vi.useRealTimers();
220 }
221 });
222});
223
224describe("emitDomainEvent", () => {
225 it("emits with automationContext depth+1", async () => {
226 const d = deps();
227 const ok = await emitDomainEvent(d.redis, "record.updated", { recordId: "r" }, ctx({ depth: 0 }));
228 expect(ok).toBe(true);
229 const call = (d.redis.xadd as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[];
230
231 expect(call[0]).toBe("factory:events");
232 expect(call[1]).toBe("*");
233 const payload = JSON.parse(call[5] as string);
234 expect(payload.automationContext).toEqual({ runId: "run_1", automationId: "auto_1", depth: 1 });
235 });
236 it("depth ≥ MAX → not emitted (returns false)", async () => {
237 const d = deps();
238 const ok = await emitDomainEvent(d.redis, "record.updated", {}, ctx({ depth: AUTOMATION_MAX_DEPTH }));
239 expect(ok).toBe(false);
240 expect(d.redis.xadd).not.toHaveBeenCalled();
241 });
242});
243
244describe("buildVars", () => {
245 it("flattens record properties by name (envelope-unwrapped, normalized)", async () => {
246 const d = deps();
247 (d.prisma as unknown as { databaseRecord: { findUnique: ReturnType<typeof vi.fn> } }).databaseRecord.findUnique.mockResolvedValue({
248 id: "rec_1",
249 properties: [
250 { value: { text: "hello", marks: [] }, property: { name: "Title", type: "TITLE", config: null } },
251 { value: { value: 42 }, property: { name: "Count", type: "NUMBER", config: null } },
252 { value: { optionId: "In Progress" }, property: { name: "Status", type: "SELECT", config: { options: [{ name: "In Progress", id: "In Progress" }] } } }
253 ]
254 });
255 const vars = await buildVars(d.prisma, ctx());
256 expect(vars.record).toMatchObject({ id: "rec_1", Title: "hello", Count: 42, Status: "In Progress" });
257 });
258 it("no record → vars.record null", async () => {
259 const vars = await buildVars(deps().prisma, ctx({ recordId: null }));
260 expect(vars.record).toBeNull();
261 });
262});
263
264
265
266
267
268type PermRow = { granteeType: string; granteeId: string; resourceType: string; resourceId: string; level: string };
269
270function permDeps(opts: {
271 role?: string | null;
272 grants?: PermRow[];
273 teams?: string[];
274}): ExecutorDeps {
275 const d = deps();
276 (d.prisma as unknown as { workspaceMember: { findUnique: ReturnType<typeof vi.fn> } }).workspaceMember.findUnique.mockResolvedValue(
277 opts.role ? { role: opts.role } : null
278 );
279 (d.prisma as unknown as { teamMember: { findMany: ReturnType<typeof vi.fn> } }).teamMember.findMany.mockResolvedValue(
280 (opts.teams ?? []).map((t) => ({ teamId: t }))
281 );
282 (d.prisma as unknown as { permission: { findMany: ReturnType<typeof vi.fn> } }).permission.findMany.mockResolvedValue(opts.grants ?? []);
283 return d;
284}
285
286describe("effectiveLevel (API mirror)", () => {
287 it("non-member → NONE", async () => {
288 expect(await effectiveLevel(permDeps({ role: null }).prisma, "ws_1", "u", { recordId: "r" })).toBe("NONE");
289 });
290 it("GUEST role floor = VIEW", async () => {
291 expect(await effectiveLevel(permDeps({ role: "GUEST" }).prisma, "ws_1", "u", { recordId: "r" })).toBe("VIEW");
292 });
293 it("MEMBER role floor = EDIT", async () => {
294 expect(await effectiveLevel(permDeps({ role: "MEMBER" }).prisma, "ws_1", "u", { recordId: "r" })).toBe("EDIT");
295 });
296 it("explicit grant raises the level (page grant + EDIT)", async () => {
297 const d = permDeps({ role: "GUEST", grants: [{ granteeType: "user", granteeId: "u", resourceType: "page", resourceId: "p", level: "EDIT" }] });
298 expect(await effectiveLevel(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p" })).toBe("EDIT");
299 });
300 it("team grant applies when the user is in the team", async () => {
301 const d = permDeps({
302 role: "GUEST",
303 teams: ["team_1"],
304 grants: [{ granteeType: "team", granteeId: "team_1", resourceType: "database", resourceId: "db", level: "FULL_ACCESS" }]
305 });
306 expect(await effectiveLevel(d.prisma, "ws_1", "u", { databaseId: "db" })).toBe("FULL_ACCESS");
307 });
308 it("explicit NONE is a hard stop (overrides a higher grant + role)", async () => {
309 const d = permDeps({
310 role: "ADMIN",
311 grants: [
312 { granteeType: "user", granteeId: "u", resourceType: "page", resourceId: "p", level: "FULL_ACCESS" },
313 { granteeType: "user", granteeId: "u", resourceType: "page", resourceId: "p", level: "NONE" }
314 ]
315 });
316 expect(await effectiveLevel(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p" })).toBe("NONE");
317 });
318 it("a NONE on a DIFFERENT resource does not fire", async () => {
319 const d = permDeps({
320 role: "MEMBER",
321 grants: [{ granteeType: "user", granteeId: "u", resourceType: "page", resourceId: "other", level: "NONE" }]
322 });
323 expect(await effectiveLevel(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p" })).toBe("EDIT");
324 });
325 it("workspace grant applies to any resource", async () => {
326 const d = permDeps({ role: null, grants: [{ granteeType: "user", granteeId: "u", resourceType: "workspace", resourceId: "ws_1", level: "COMMENT" }] });
327 expect(await effectiveLevel(d.prisma, "ws_1", "u", { recordId: "r" })).toBe("COMMENT");
328 });
329});
330
331describe("assertAutomationCanEdit", () => {
332 it("denied → FatalError PERMISSION_DENIED", async () => {
333 const d = permDeps({ role: "GUEST" });
334 await expectRejectsCode(
335 () => assertAutomationCanEdit(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p", databaseId: "db" }),
336 "PERMISSION_DENIED"
337 );
338 });
339 it("allowed (MEMBER has EDIT)", async () => {
340 const d = permDeps({ role: "MEMBER" });
341 await expect(assertAutomationCanEdit(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p", databaseId: "db" })).resolves.toBeUndefined();
342 });
343});
344
345
346
347
348
349describe("writeRecordProperties", () => {
350 function recordWriteDeps(props: PropertyMetaLite[], role: string) {
351 const d = deps();
352 (d.prisma as unknown as { databaseRecord: { findUnique: ReturnType<typeof vi.fn> } }).databaseRecord.findUnique.mockResolvedValue({
353 id: "rec_1",
354 workspaceId: "ws_1",
355 databaseId: "db_1",
356 pageId: "p_1"
357 });
358 (d.prisma as unknown as { workspaceMember: { findUnique: ReturnType<typeof vi.fn> } }).workspaceMember.findUnique.mockResolvedValue({ role });
359 (d.prisma as unknown as { databaseProperty: { findMany: ReturnType<typeof vi.fn> } }).databaseProperty.findMany.mockResolvedValue(props);
360 return d;
361 }
362
363 it("coerces values to envelopes, upserts, emits record.updated", async () => {
364 const d = recordWriteDeps(
365 [
366 { id: "prop_title_1", name: "Title", type: "TITLE", isTitle: true, config: null },
367 { id: "prop_status_1", name: "Status", type: "SELECT", isTitle: false, config: { options: [{ name: "Done", id: "Done" }] } }
368 ],
369 "MEMBER"
370 );
371 const result = await writeRecordProperties(d, ctx(), "rec_1", { Title: "New", Status: "Done" });
372 expect(result.changed).toEqual(["Title", "Status"]);
373 expect(result.eventEmitted).toBe(true);
374
375 const upserts = (d.prisma as unknown as { recordProperty: { upsert: ReturnType<typeof vi.fn> } }).recordProperty.upsert.mock.calls;
376 const statusCall = upserts.find((c) => (c[0] as { where: { recordId_propertyId: { propertyId: string } } }).where.recordId_propertyId.propertyId === "prop_status_1") as unknown[];
377 expect((statusCall[0] as { update: { value: unknown } }).update.value).toEqual({ optionId: "Done" });
378
379 const call = (d.redis.xadd as ReturnType<typeof vi.fn>).mock.calls[0] as unknown[];
380 const payload = JSON.parse(call[5] as string);
381 expect(payload.changedProperties).toEqual(["Title", "Status"]);
382 expect(payload.automationContext.depth).toBe(1);
383 });
384 it("unknown property → FatalError PROPERTY_UNKNOWN", async () => {
385 const d = recordWriteDeps([], "MEMBER");
386 await expectRejectsCode(() => writeRecordProperties(d, ctx(), "rec_1", { Ghost: "x" }), "PROPERTY_UNKNOWN");
387 });
388 it("permission denied (GUEST) → PERMISSION_DENIED, no writes", async () => {
389 const d = recordWriteDeps([{ id: "p_title_1", name: "Title", type: "TITLE", isTitle: true, config: null }], "GUEST");
390 await expectRejectsCode(() => writeRecordProperties(d, ctx(), "rec_1", { Title: "x" }), "PERMISSION_DENIED");
391 expect((d.prisma as unknown as { recordProperty: { upsert: ReturnType<typeof vi.fn> } }).recordProperty.upsert).not.toHaveBeenCalled();
392 });
393 it("null value → skipped (not an error)", async () => {
394 const d = recordWriteDeps([{ id: "p_title_1", name: "Title", type: "TITLE", isTitle: true, config: null }], "MEMBER");
395 const result = await writeRecordProperties(d, ctx(), "rec_1", { Title: null });
396 expect(result.changed).toEqual([]);
397 expect((d.prisma as unknown as { recordProperty: { upsert: ReturnType<typeof vi.fn> } }).recordProperty.upsert).not.toHaveBeenCalled();
398 });
399});
400
401
402
403
404
405describe("reserveEmailSlot", () => {
406 it("increments + arms the 24h TTL on the first slot", async () => {
407 const d = deps();
408 (d.redis as unknown as { incr: ReturnType<typeof vi.fn> }).incr.mockResolvedValue(1);
409 const n = await reserveEmailSlot(d.redis, "ws_1");
410 expect(n).toBe(1);
411 expect((d.redis as unknown as { expire: ReturnType<typeof vi.fn> }).expire).toHaveBeenCalledWith(emailCapKey("ws_1"), 24 * 60 * 60);
412 });
413 it("does not re-arm TTL after the first slot", async () => {
414 const d = deps();
415 (d.redis as unknown as { incr: ReturnType<typeof vi.fn> }).incr.mockResolvedValue(2);
416 await reserveEmailSlot(d.redis, "ws_1");
417 expect((d.redis as unknown as { expire: ReturnType<typeof vi.fn> }).expire).not.toHaveBeenCalled();
418 });
419 it("exceeding the cap → FatalError EMAIL_DAILY_CAP", async () => {
420 const d = deps();
421 (d.redis as unknown as { incr: ReturnType<typeof vi.fn> }).incr.mockResolvedValue(AUTOMATION_EMAIL_DAILY_CAP + 1);
422 await expectRejectsCode(() => reserveEmailSlot(d.redis, "ws_1"), "EMAIL_DAILY_CAP");
423 });
424});
425
426
427
428
429
430describe("validateAction", () => {
431 it("unknown type → INVALID_CONFIG", () => {
432 expectThrowsCode(() => validateAction({ type: "BOGUS", config: {} }), "INVALID_CONFIG");
433 });
434 it("invalid config → INVALID_CONFIG (path-located)", () => {
435
436 expectThrowsCode(() => validateAction({ type: "UPDATE_RECORD", config: { propertyValues: {} } }), "INVALID_CONFIG");
437 });
438 it("valid → normalized with default retries/timeoutMs", () => {
439 const v = validateAction({ type: "UPDATE_RECORD", config: { propertyValues: { Title: "x" } } });
440 expect(v.type).toBe("UPDATE_RECORD");
441 expect(v.config.recordId).toBe("from_trigger");
442 expect(v.retries).toBe(3);
443 expect(v.timeoutMs).toBe(30000);
444 });
445});
446
447describe("registry dispatch", () => {
448 it("all 9 action types are registered", () => {
449 for (const t of [
450 "UPDATE_RECORD",
451 "CREATE_PAGE",
452 "SEND_EMAIL",
453 "SEND_WEBHOOK",
454 "CREATE_NOTIFICATION",
455 "ASSIGN_USER",
456 "CHANGE_STATUS",
457 "CALL_INTEGRATION",
458 "RUN_AI_ACTION"
459 ]) {
460 expect(executorFor(t)?.type).toBe(t);
461 }
462 expect(executorFor("NOPE")).toBeUndefined();
463 });
464 it("RUN_AI_ACTION → fatal AI_ACTION_NOT_AVAILABLE", async () => {
465 await expectRejectsCode(
466 () => executeAction({ type: "RUN_AI_ACTION", config: { prompt: "summarize this", task: "summarize" } }, ctx(), deps(), 0),
467 "AI_ACTION_NOT_AVAILABLE"
468 );
469 });
470 it("CALL_INTEGRATION with no integration → INTEGRATION_NOT_FOUND", async () => {
471 const d = deps();
472 (d.prisma as unknown as { integration: { findFirst: ReturnType<typeof vi.fn> } }).integration.findFirst.mockResolvedValue(null);
473 await expectRejectsCode(
474 () => executeAction({ type: "CALL_INTEGRATION", config: { integrationId: "integration_1", operation: "x" } }, ctx(), d, 0),
475 "INTEGRATION_NOT_FOUND"
476 );
477 });
478 it("CALL_INTEGRATION with an integration but no adapter → INTEGRATION_NOT_AVAILABLE (Phase 13)", async () => {
479 const d = deps();
480 (d.prisma as unknown as { integration: { findFirst: ReturnType<typeof vi.fn> } }).integration.findFirst.mockResolvedValue({
481 id: "integration_1",
482 provider: "github",
483 status: "CONNECTED"
484 });
485 await expectRejectsCode(
486 () => executeAction({ type: "CALL_INTEGRATION", config: { integrationId: "integration_1", operation: "x" } }, ctx(), d, 0),
487 "INTEGRATION_NOT_AVAILABLE"
488 );
489 });
490 it("UPDATE_RECORD dispatches through the shared tail", async () => {
491 const d = deps();
492 (d.prisma as unknown as { databaseRecord: { findUnique: ReturnType<typeof vi.fn> } }).databaseRecord.findUnique.mockResolvedValue({
493 id: "rec_1",
494 workspaceId: "ws_1",
495 databaseId: "db_1",
496 pageId: "p_1"
497 });
498 (d.prisma as unknown as { workspaceMember: { findUnique: ReturnType<typeof vi.fn> } }).workspaceMember.findUnique.mockResolvedValue({ role: "MEMBER" });
499 (d.prisma as unknown as { databaseProperty: { findMany: ReturnType<typeof vi.fn> } }).databaseProperty.findMany.mockResolvedValue([
500 { id: "p_title_1", name: "Title", type: "TITLE", isTitle: true, config: null }
501 ]);
502 const res = await executeAction(
503 { type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Title: "Hi" } } },
504 ctx(),
505 d,
506 0
507 );
508 expect(res.output.changed).toEqual(["Title"]);
509 expect(res.audit?.action).toBe("automation.record_updated");
510 });
511});
512