/** * TASK-0112 — executor primitives + registry unit tests. * * Covers: classifyError (retryable vs fatal matrix), helpers (record/user * resolution, interpolation overflow + deep, timeout, domain-event depth * guard), permission (the API effectiveLevel mirror: role baseline, grant * max, explicit-NONE hard stop, non-member), the shared record-write tail, * the email daily cap, and the registry (unknown type / invalid config / * all 9 executors registered). No infrastructure — pure fakes. * * NOTE: Fatal/Retryable errors carry their code on `.code` (not the message), * so code assertions use `expectThrowsCode` / `expectRejectsCode`. */ import { describe, expect, it, vi } from "vitest"; import { FatalError, RetryableError, classifyError } from "../executors/classify.js"; import { emitDomainEvent, interpolateConfig, interpolateDeep, resolveRecordId, resolveUserId, runWithTimeout, buildVars, type PropertyMetaLite } from "../executors/helpers.js"; import { effectiveLevel, assertAutomationCanEdit } from "../executors/permission.js"; import { reserveEmailSlot } from "../executors/email-cap.js"; import { executeAction, executorFor, validateAction } from "../executors/registry.js"; import { writeRecordProperties } from "../executors/record-write.js"; import { AUTOMATION_EMAIL_DAILY_CAP, AUTOMATION_MAX_DEPTH, emailCapKey } from "@factory/shared"; import type { AutomationRunContext, ExecutorDeps } from "../executors/types.js"; // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- /** Assert a sync fn throws and the thrown error carries `.code`. */ function expectThrowsCode(fn: () => unknown, code: string): void { let err: unknown; try { fn(); } catch (e) { err = e; } expect(err, "expected the function to throw").toBeDefined(); expect((err as { code?: string }).code).toBe(code); } /** Assert an async fn rejects and the rejection carries `.code`. */ async function expectRejectsCode(fn: () => Promise, code: string): Promise { let err: unknown; try { await fn(); } catch (e) { err = e; } expect(err, "expected the promise to reject").toBeDefined(); expect((err as { code?: string }).code).toBe(code); } const ctx = (over: Partial = {}): AutomationRunContext => ({ runId: "run_1", automationId: "auto_1", automationName: "Test automation", workspaceId: "ws_1", databaseId: "db_1", recordId: "rec_1", triggerEvent: { type: "record.updated", payload: {} }, depth: 0, input: null, actorUserId: "user_creator", vars: { record: null, event: {}, input: null, automation: { id: "auto_1", name: "Test automation" }, run: { id: "run_1" } }, ...over }); const deps = (over: Partial = {}): ExecutorDeps => ({ prisma: { databaseRecord: { findUnique: vi.fn().mockResolvedValue(null) }, databaseProperty: { findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]) }, recordProperty: { upsert: vi.fn().mockResolvedValue({}) }, page: { create: vi.fn(), findUnique: vi.fn().mockResolvedValue(null) }, database: { findUnique: vi.fn().mockResolvedValue(null) }, user: { findUnique: vi.fn().mockResolvedValue(null) }, workspaceMember: { findUnique: vi.fn().mockResolvedValue(null) }, teamMember: { findMany: vi.fn().mockResolvedValue([]) }, permission: { findMany: vi.fn().mockResolvedValue([]) }, notification: { create: vi.fn().mockResolvedValue({ id: "ntf_1" }) }, integration: { findFirst: vi.fn().mockResolvedValue(null) }, auditLog: { create: vi.fn().mockResolvedValue({}) }, $transaction: vi.fn() } as unknown as ExecutorDeps["prisma"], redis: { xadd: vi.fn().mockResolvedValue("1-1"), incr: vi.fn().mockResolvedValue(1), expire: vi.fn().mockResolvedValue(1) } as unknown as ExecutorDeps["redis"], env: {} as ExecutorDeps["env"], ...over }); // --------------------------------------------------------------------------- // classifyError // --------------------------------------------------------------------------- describe("classifyError", () => { it("FatalError → not retryable, its code", () => { expect(classifyError(new FatalError("ACTION_TIMEOUT", "x"))).toEqual({ retryable: false, code: "ACTION_TIMEOUT" }); }); it("RetryableError → retryable, its code", () => { expect(classifyError(new RetryableError("NET_ECONNRESET", "x"))).toEqual({ retryable: true, code: "NET_ECONNRESET" }); }); it("Postgres transient SQLSTATE (40P01 deadlock) → retryable", () => { const e = new Error("deadlock"); (e as unknown as { code: string }).code = "40P01"; expect(classifyError(e).retryable).toBe(true); }); it("Postgres serialization (40001) → retryable", () => { const e = new Error("serialization"); (e as unknown as { code: string }).code = "40001"; expect(classifyError(e).retryable).toBe(true); }); it("network errno ECONNRESET → retryable", () => { const e = new Error("reset"); (e as unknown as { errno: string }).errno = "ECONNRESET"; expect(classifyError(e)).toEqual({ retryable: true, code: "NET_ECONNRESET" }); }); it("unknown error → fatal (fail closed)", () => { expect(classifyError(new Error("boom"))).toEqual({ retryable: false, code: "UNEXPECTED" }); }); it("TimeoutError → fatal ACTION_TIMEOUT", () => { const e = new Error("timeout"); e.name = "TimeoutError"; expect(classifyError(e)).toEqual({ retryable: false, code: "ACTION_TIMEOUT" }); }); }); // --------------------------------------------------------------------------- // helpers — resolution // --------------------------------------------------------------------------- describe("resolveRecordId", () => { it("from_trigger → ctx.recordId", () => { expect(resolveRecordId({ recordId: "from_trigger" }, ctx())).toBe("rec_1"); }); it("literal id passthrough", () => { expect(resolveRecordId({ recordId: "rec_9" }, ctx())).toBe("rec_9"); }); it("from_trigger with no trigger record → NO_TRIGGER_RECORD", () => { expectThrowsCode(() => resolveRecordId({ recordId: "from_trigger" }, ctx({ recordId: null })), "NO_TRIGGER_RECORD"); }); }); describe("resolveUserId", () => { it("literal cuid passthrough", () => { expect(resolveUserId("user_x", ctx())).toBe("user_x"); }); it("automation_creator → ctx.actorUserId", () => { expect(resolveUserId("automation_creator", ctx())).toBe("user_creator"); }); it("trigger_user → event payload createdBy", () => { const c = ctx({ triggerEvent: { type: "record.created", payload: {}, createdBy: "user_event" } }); expect(resolveUserId("trigger_user", c)).toBe("user_event"); }); it("unresolvable → NO_TARGET_USER", () => { expectThrowsCode(() => resolveUserId("trigger_user", ctx()), "NO_TARGET_USER"); }); }); // --------------------------------------------------------------------------- // helpers — interpolation // --------------------------------------------------------------------------- describe("interpolateConfig", () => { const vars = ctx().vars; it("replaces known keys", () => { expect(interpolateConfig("hi {{automation.name}}", vars, 200, "s", [])).toBe("hi Test automation"); }); it("unknown key → '' + warning", () => { const w: string[] = []; expect(interpolateConfig("{{nope.x}}", vars, 200, "s", w)).toBe(""); expect(w.length).toBeGreaterThan(0); }); it("overflow → INTERPOLATION_OVERFLOW (fatal)", () => { expectThrowsCode(() => interpolateConfig("x".repeat(300), vars, 200, "s", []), "INTERPOLATION_OVERFLOW"); }); }); describe("interpolateDeep", () => { const vars = ctx().vars; it("interpolates strings, passes through numbers/booleans/null", () => { const out = interpolateDeep( { a: "{{automation.id}}", b: 5, c: true, d: null, e: ["{{automation.id}}", 7] }, vars, [] ) as Record; expect(out.a).toBe("auto_1"); expect(out.b).toBe(5); expect(out.c).toBe(true); expect(out.d).toBeNull(); expect(out.e).toEqual(["auto_1", 7]); }); }); describe("runWithTimeout", () => { it("resolves when the task finishes in time", async () => { await expect(runWithTimeout(1000, () => Promise.resolve(42))).resolves.toBe(42); }); it("timeout → fatal ACTION_TIMEOUT", async () => { // Fake timers: deterministic, and the rejection is handled within the // test's control flow (no unhandled-rejection artifact from a 1 ms real // timer racing the detector). In production the wrapped task always // settles (DB/network), so this is purely a test-timing concern. vi.useFakeTimers(); try { const settled = runWithTimeout(100, () => new Promise(() => {})).catch((e) => (e as { code?: string }).code); await vi.advanceTimersByTimeAsync(150); await expect(settled).resolves.toBe("ACTION_TIMEOUT"); } finally { vi.useRealTimers(); } }); }); describe("emitDomainEvent", () => { it("emits with automationContext depth+1", async () => { const d = deps(); const ok = await emitDomainEvent(d.redis, "record.updated", { recordId: "r" }, ctx({ depth: 0 })); expect(ok).toBe(true); const call = (d.redis.xadd as ReturnType).mock.calls[0] as unknown[]; // flat-pair form: [key, id, "event", event, "payload", , "ts", ts] expect(call[0]).toBe("factory:events"); expect(call[1]).toBe("*"); const payload = JSON.parse(call[5] as string); expect(payload.automationContext).toEqual({ runId: "run_1", automationId: "auto_1", depth: 1 }); }); it("depth ≥ MAX → not emitted (returns false)", async () => { const d = deps(); const ok = await emitDomainEvent(d.redis, "record.updated", {}, ctx({ depth: AUTOMATION_MAX_DEPTH })); expect(ok).toBe(false); expect(d.redis.xadd).not.toHaveBeenCalled(); }); }); describe("buildVars", () => { it("flattens record properties by name (envelope-unwrapped, normalized)", async () => { const d = deps(); (d.prisma as unknown as { databaseRecord: { findUnique: ReturnType } }).databaseRecord.findUnique.mockResolvedValue({ id: "rec_1", properties: [ { value: { text: "hello", marks: [] }, property: { name: "Title", type: "TITLE", config: null } }, { value: { value: 42 }, property: { name: "Count", type: "NUMBER", config: null } }, { value: { optionId: "In Progress" }, property: { name: "Status", type: "SELECT", config: { options: [{ name: "In Progress", id: "In Progress" }] } } } ] }); const vars = await buildVars(d.prisma, ctx()); expect(vars.record).toMatchObject({ id: "rec_1", Title: "hello", Count: 42, Status: "In Progress" }); }); it("no record → vars.record null", async () => { const vars = await buildVars(deps().prisma, ctx({ recordId: null })); expect(vars.record).toBeNull(); }); }); // --------------------------------------------------------------------------- // permission — effectiveLevel mirror // --------------------------------------------------------------------------- type PermRow = { granteeType: string; granteeId: string; resourceType: string; resourceId: string; level: string }; function permDeps(opts: { role?: string | null; grants?: PermRow[]; teams?: string[]; }): ExecutorDeps { const d = deps(); (d.prisma as unknown as { workspaceMember: { findUnique: ReturnType } }).workspaceMember.findUnique.mockResolvedValue( opts.role ? { role: opts.role } : null ); (d.prisma as unknown as { teamMember: { findMany: ReturnType } }).teamMember.findMany.mockResolvedValue( (opts.teams ?? []).map((t) => ({ teamId: t })) ); (d.prisma as unknown as { permission: { findMany: ReturnType } }).permission.findMany.mockResolvedValue(opts.grants ?? []); return d; } describe("effectiveLevel (API mirror)", () => { it("non-member → NONE", async () => { expect(await effectiveLevel(permDeps({ role: null }).prisma, "ws_1", "u", { recordId: "r" })).toBe("NONE"); }); it("GUEST role floor = VIEW", async () => { expect(await effectiveLevel(permDeps({ role: "GUEST" }).prisma, "ws_1", "u", { recordId: "r" })).toBe("VIEW"); }); it("MEMBER role floor = EDIT", async () => { expect(await effectiveLevel(permDeps({ role: "MEMBER" }).prisma, "ws_1", "u", { recordId: "r" })).toBe("EDIT"); }); it("explicit grant raises the level (page grant + EDIT)", async () => { const d = permDeps({ role: "GUEST", grants: [{ granteeType: "user", granteeId: "u", resourceType: "page", resourceId: "p", level: "EDIT" }] }); expect(await effectiveLevel(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p" })).toBe("EDIT"); }); it("team grant applies when the user is in the team", async () => { const d = permDeps({ role: "GUEST", teams: ["team_1"], grants: [{ granteeType: "team", granteeId: "team_1", resourceType: "database", resourceId: "db", level: "FULL_ACCESS" }] }); expect(await effectiveLevel(d.prisma, "ws_1", "u", { databaseId: "db" })).toBe("FULL_ACCESS"); }); it("explicit NONE is a hard stop (overrides a higher grant + role)", async () => { const d = permDeps({ role: "ADMIN", grants: [ { granteeType: "user", granteeId: "u", resourceType: "page", resourceId: "p", level: "FULL_ACCESS" }, { granteeType: "user", granteeId: "u", resourceType: "page", resourceId: "p", level: "NONE" } ] }); expect(await effectiveLevel(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p" })).toBe("NONE"); }); it("a NONE on a DIFFERENT resource does not fire", async () => { const d = permDeps({ role: "MEMBER", grants: [{ granteeType: "user", granteeId: "u", resourceType: "page", resourceId: "other", level: "NONE" }] }); expect(await effectiveLevel(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p" })).toBe("EDIT"); }); it("workspace grant applies to any resource", async () => { const d = permDeps({ role: null, grants: [{ granteeType: "user", granteeId: "u", resourceType: "workspace", resourceId: "ws_1", level: "COMMENT" }] }); expect(await effectiveLevel(d.prisma, "ws_1", "u", { recordId: "r" })).toBe("COMMENT"); }); }); describe("assertAutomationCanEdit", () => { it("denied → FatalError PERMISSION_DENIED", async () => { const d = permDeps({ role: "GUEST" }); await expectRejectsCode( () => assertAutomationCanEdit(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p", databaseId: "db" }), "PERMISSION_DENIED" ); }); it("allowed (MEMBER has EDIT)", async () => { const d = permDeps({ role: "MEMBER" }); await expect(assertAutomationCanEdit(d.prisma, "ws_1", "u", { recordId: "r", pageId: "p", databaseId: "db" })).resolves.toBeUndefined(); }); }); // --------------------------------------------------------------------------- // record-write tail // --------------------------------------------------------------------------- describe("writeRecordProperties", () => { function recordWriteDeps(props: PropertyMetaLite[], role: string) { const d = deps(); (d.prisma as unknown as { databaseRecord: { findUnique: ReturnType } }).databaseRecord.findUnique.mockResolvedValue({ id: "rec_1", workspaceId: "ws_1", databaseId: "db_1", pageId: "p_1" }); (d.prisma as unknown as { workspaceMember: { findUnique: ReturnType } }).workspaceMember.findUnique.mockResolvedValue({ role }); (d.prisma as unknown as { databaseProperty: { findMany: ReturnType } }).databaseProperty.findMany.mockResolvedValue(props); return d; } it("coerces values to envelopes, upserts, emits record.updated", async () => { const d = recordWriteDeps( [ { id: "prop_title_1", name: "Title", type: "TITLE", isTitle: true, config: null }, { id: "prop_status_1", name: "Status", type: "SELECT", isTitle: false, config: { options: [{ name: "Done", id: "Done" }] } } ], "MEMBER" ); const result = await writeRecordProperties(d, ctx(), "rec_1", { Title: "New", Status: "Done" }); expect(result.changed).toEqual(["Title", "Status"]); expect(result.eventEmitted).toBe(true); // the SELECT value is stored as the envelope { optionId: } const upserts = (d.prisma as unknown as { recordProperty: { upsert: ReturnType } }).recordProperty.upsert.mock.calls; const statusCall = upserts.find((c) => (c[0] as { where: { recordId_propertyId: { propertyId: string } } }).where.recordId_propertyId.propertyId === "prop_status_1") as unknown[]; expect((statusCall[0] as { update: { value: unknown } }).update.value).toEqual({ optionId: "Done" }); // record.updated emitted with changedProperties + depth stamp (flat-pair xadd) const call = (d.redis.xadd as ReturnType).mock.calls[0] as unknown[]; const payload = JSON.parse(call[5] as string); expect(payload.changedProperties).toEqual(["Title", "Status"]); expect(payload.automationContext.depth).toBe(1); }); it("unknown property → FatalError PROPERTY_UNKNOWN", async () => { const d = recordWriteDeps([], "MEMBER"); await expectRejectsCode(() => writeRecordProperties(d, ctx(), "rec_1", { Ghost: "x" }), "PROPERTY_UNKNOWN"); }); it("permission denied (GUEST) → PERMISSION_DENIED, no writes", async () => { const d = recordWriteDeps([{ id: "p_title_1", name: "Title", type: "TITLE", isTitle: true, config: null }], "GUEST"); await expectRejectsCode(() => writeRecordProperties(d, ctx(), "rec_1", { Title: "x" }), "PERMISSION_DENIED"); expect((d.prisma as unknown as { recordProperty: { upsert: ReturnType } }).recordProperty.upsert).not.toHaveBeenCalled(); }); it("null value → skipped (not an error)", async () => { const d = recordWriteDeps([{ id: "p_title_1", name: "Title", type: "TITLE", isTitle: true, config: null }], "MEMBER"); const result = await writeRecordProperties(d, ctx(), "rec_1", { Title: null }); expect(result.changed).toEqual([]); expect((d.prisma as unknown as { recordProperty: { upsert: ReturnType } }).recordProperty.upsert).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // email cap // --------------------------------------------------------------------------- describe("reserveEmailSlot", () => { it("increments + arms the 24h TTL on the first slot", async () => { const d = deps(); (d.redis as unknown as { incr: ReturnType }).incr.mockResolvedValue(1); const n = await reserveEmailSlot(d.redis, "ws_1"); expect(n).toBe(1); expect((d.redis as unknown as { expire: ReturnType }).expire).toHaveBeenCalledWith(emailCapKey("ws_1"), 24 * 60 * 60); }); it("does not re-arm TTL after the first slot", async () => { const d = deps(); (d.redis as unknown as { incr: ReturnType }).incr.mockResolvedValue(2); await reserveEmailSlot(d.redis, "ws_1"); expect((d.redis as unknown as { expire: ReturnType }).expire).not.toHaveBeenCalled(); }); it("exceeding the cap → FatalError EMAIL_DAILY_CAP", async () => { const d = deps(); (d.redis as unknown as { incr: ReturnType }).incr.mockResolvedValue(AUTOMATION_EMAIL_DAILY_CAP + 1); await expectRejectsCode(() => reserveEmailSlot(d.redis, "ws_1"), "EMAIL_DAILY_CAP"); }); }); // --------------------------------------------------------------------------- // registry // --------------------------------------------------------------------------- describe("validateAction", () => { it("unknown type → INVALID_CONFIG", () => { expectThrowsCode(() => validateAction({ type: "BOGUS", config: {} }), "INVALID_CONFIG"); }); it("invalid config → INVALID_CONFIG (path-located)", () => { // UPDATE_RECORD requires ≥1 propertyValues entry. expectThrowsCode(() => validateAction({ type: "UPDATE_RECORD", config: { propertyValues: {} } }), "INVALID_CONFIG"); }); it("valid → normalized with default retries/timeoutMs", () => { const v = validateAction({ type: "UPDATE_RECORD", config: { propertyValues: { Title: "x" } } }); expect(v.type).toBe("UPDATE_RECORD"); expect(v.config.recordId).toBe("from_trigger"); expect(v.retries).toBe(3); expect(v.timeoutMs).toBe(30000); }); }); describe("registry dispatch", () => { it("all 9 action types are registered", () => { for (const t of [ "UPDATE_RECORD", "CREATE_PAGE", "SEND_EMAIL", "SEND_WEBHOOK", "CREATE_NOTIFICATION", "ASSIGN_USER", "CHANGE_STATUS", "CALL_INTEGRATION", "RUN_AI_ACTION" ]) { expect(executorFor(t)?.type).toBe(t); } expect(executorFor("NOPE")).toBeUndefined(); }); it("RUN_AI_ACTION → fatal AI_ACTION_NOT_AVAILABLE", async () => { await expectRejectsCode( () => executeAction({ type: "RUN_AI_ACTION", config: { prompt: "summarize this", task: "summarize" } }, ctx(), deps(), 0), "AI_ACTION_NOT_AVAILABLE" ); }); it("CALL_INTEGRATION with no integration → INTEGRATION_NOT_FOUND", async () => { const d = deps(); (d.prisma as unknown as { integration: { findFirst: ReturnType } }).integration.findFirst.mockResolvedValue(null); await expectRejectsCode( () => executeAction({ type: "CALL_INTEGRATION", config: { integrationId: "integration_1", operation: "x" } }, ctx(), d, 0), "INTEGRATION_NOT_FOUND" ); }); it("CALL_INTEGRATION with an integration but no adapter → INTEGRATION_NOT_AVAILABLE (Phase 13)", async () => { const d = deps(); (d.prisma as unknown as { integration: { findFirst: ReturnType } }).integration.findFirst.mockResolvedValue({ id: "integration_1", provider: "github", status: "CONNECTED" }); await expectRejectsCode( () => executeAction({ type: "CALL_INTEGRATION", config: { integrationId: "integration_1", operation: "x" } }, ctx(), d, 0), "INTEGRATION_NOT_AVAILABLE" ); }); it("UPDATE_RECORD dispatches through the shared tail", async () => { const d = deps(); (d.prisma as unknown as { databaseRecord: { findUnique: ReturnType } }).databaseRecord.findUnique.mockResolvedValue({ id: "rec_1", workspaceId: "ws_1", databaseId: "db_1", pageId: "p_1" }); (d.prisma as unknown as { workspaceMember: { findUnique: ReturnType } }).workspaceMember.findUnique.mockResolvedValue({ role: "MEMBER" }); (d.prisma as unknown as { databaseProperty: { findMany: ReturnType } }).databaseProperty.findMany.mockResolvedValue([ { id: "p_title_1", name: "Title", type: "TITLE", isTitle: true, config: null } ]); const res = await executeAction( { type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Title: "Hi" } } }, ctx(), d, 0 ); expect(res.output.changed).toEqual(["Title"]); expect(res.audit?.action).toBe("automation.record_updated"); }); });