/** * apps/worker — TASK-0113 run-pipeline UNIT tests. * * Pins the four pure pieces the spec's TASK-0113 "Tests" line requires: * 1. state machine — every invalid (from→to) pair throws; every valid one passes * 2. backoff — attempt n delay = 2^(n-1) s, capped at 600 s * 3. log truncation — over-cap `data` blobs get a `truncated: true` flag and * sensitive keys are redacted to `***` * 4. emission rule — `automation.executed` fires ONLY at depth 0 (no storm) * Plus the shared loop guard (self-trigger block + depth limit) that the * pipeline consults before any action runs. * * All pure / dependency-injected: no Postgres, no Redis, no BullMQ. A fake * Redis records `xadd` calls so the emission rule is observable. */ import { describe, expect, it, vi } from "vitest"; import { runBackoffSeconds, AUTOMATION_MAX_DEPTH, loopGuard } from "@factory/shared"; import { assertTransition, canTransition, isTerminal, TERMINAL } from "../run-state.js"; import { redact, truncateLogData, LOG_DATA_MAX_BYTES } from "../log.js"; import { emitAutomationExecuted } from "../dead-letter.js"; import { autoRunMaxRetries } from "../pipeline.js"; // --------------------------------------------------------------------------- // 1. State machine // --------------------------------------------------------------------------- const ALL = ["PENDING", "RUNNING", "SUCCEEDED", "FAILED", "DEAD_LETTER"] as const; const VALID: ReadonlyArray = [ ["PENDING", "RUNNING"], ["RUNNING", "SUCCEEDED"], ["RUNNING", "FAILED"], ["RUNNING", "DEAD_LETTER"], ["FAILED", "PENDING"], ["FAILED", "DEAD_LETTER"], ["PENDING", "FAILED"], ["DEAD_LETTER", "PENDING"] ]; describe("run state machine", () => { it("accepts exactly the spec's valid transitions", () => { for (const [from, to] of VALID) { expect(canTransition(from, to), `${from} → ${to} should be valid`).toBe(true); expect(() => assertTransition(from, to), `${from} → ${to} should not throw`).not.toThrow(); } }); it("rejects every other transition (25 total − 8 valid − 5 self-loops)", () => { let rejected = 0; for (const from of ALL) { for (const to of ALL) { const isValid = VALID.some(([f, t]) => f === from && t === to); if (from === to) { // self-loop is never a transition expect(canTransition(from, to), `${from} → ${from} should be invalid`).toBe(false); expect(() => assertTransition(from, to), `${from} → ${from} should throw`).toThrow( /invalid automation run transition/ ); continue; } if (!isValid) { expect(canTransition(from, to), `${from} → ${to} should be invalid`).toBe(false); expect(() => assertTransition(from, to), `${from} → ${to} should throw`).toThrow( /invalid automation run transition/ ); rejected++; } } } // 5×5 = 25 pairs; 8 valid; 5 self-loops; 25 - 8 - 5 = 12 invalid non-self pairs. expect(rejected).toBe(12); }); it("SUCCEEDED and DEAD_LETTER are the terminal states", () => { expect(TERMINAL).toEqual(["SUCCEEDED", "DEAD_LETTER"]); for (const t of ALL) { expect(isTerminal(t), `${t} terminal?`).toBe(t === "SUCCEEDED" || t === "DEAD_LETTER"); } }); it("terminal states have no outgoing edges", () => { for (const from of TERMINAL) { for (const to of ALL) { if (from === to) continue; if (from === "DEAD_LETTER" && to === "PENDING") continue; // manual retry expect(canTransition(from, to), `${from} → ${to} should be invalid`).toBe(false); } } }); }); // --------------------------------------------------------------------------- // 2. Backoff schedule // --------------------------------------------------------------------------- describe("run backoff schedule (2^(n-1) s, cap 600 s)", () => { it("returns 0 for sub-1 / non-integer attempts", () => { expect(runBackoffSeconds(0)).toBe(0); expect(runBackoffSeconds(-1)).toBe(0); expect(runBackoffSeconds(1.5)).toBe(0); expect(runBackoffSeconds(NaN)).toBe(0); }); it("doubles each attempt until the cap", () => { // attempt 1 = first retry → 2^0 = 1 s expect(runBackoffSeconds(1)).toBe(1); expect(runBackoffSeconds(2)).toBe(2); expect(runBackoffSeconds(3)).toBe(4); expect(runBackoffSeconds(4)).toBe(8); expect(runBackoffSeconds(5)).toBe(16); expect(runBackoffSeconds(6)).toBe(32); expect(runBackoffSeconds(7)).toBe(64); expect(runBackoffSeconds(8)).toBe(128); expect(runBackoffSeconds(9)).toBe(256); expect(runBackoffSeconds(10)).toBe(512); // 2^9 = 512, still under the cap }); it("caps at 600 s", () => { // attempt n → 2^(n-1): 2^9 = 512 (< 600), 2^10 = 1024 (→ capped 600) expect(runBackoffSeconds(10)).toBe(512); expect(runBackoffSeconds(11)).toBe(600); expect(runBackoffSeconds(99)).toBe(600); // the schedule is monotonically non-decreasing and bounded let prev = 0; for (let n = 1; n <= 30; n++) { const d = runBackoffSeconds(n); expect(d).toBeGreaterThanOrEqual(prev); expect(d).toBeLessThanOrEqual(600); prev = d; } }); it("autoRunMaxRetries honours the env override, else 8", () => { const env = (v: unknown): Parameters[0] => ({ AUTO_RUN_MAX_RETRIES: v }) as Parameters[0]; expect(autoRunMaxRetries(env(8))).toBe(8); expect(autoRunMaxRetries(env(3))).toBe(3); expect(autoRunMaxRetries(env(0))).toBe(8); // < 1 → default expect(autoRunMaxRetries(env(undefined))).toBe(8); expect(autoRunMaxRetries(env(2.5))).toBe(8); // non-integer → default }); }); // --------------------------------------------------------------------------- // 3. Log redaction + truncation // --------------------------------------------------------------------------- describe("log redaction", () => { it("masks sensitive keys (case-insensitive, exact-name) recursively", () => { const out = redact({ Authorization: "Bearer abc", "x-api-key": "secret", COOKIE: "sid=1", nested: { "webhook-secret": "shh", token: "t", keyboard: "a key-like NON-secret" }, list: [{ password: "p", public: "ok" }] }) as Record; expect(out.Authorization).toBe("***"); expect(out["x-api-key"]).toBe("***"); expect(out.COOKIE).toBe("***"); const nested = out.nested as Record; expect(nested["webhook-secret"]).toBe("***"); expect(nested.token).toBe("***"); // exact-name match only — a key that merely CONTAINS "key" is not masked expect(nested.keyboard).toBe("a key-like NON-secret"); // noUncheckedIndexedAccess: assert on the (possibly-undefined) element expect((out.list as Array>)[0]).toMatchObject({ password: "***", public: "ok" }); }); it("passes scalars and unknown structures through", () => { expect(redact("plain")).toBe("plain"); expect(redact(42)).toBe(42); expect(redact(null)).toBe(null); expect(redact([1, 2, { k: "v" }])).toEqual([1, 2, { k: "v" }]); }); }); describe("log truncation (LOG_DATA_MAX_BYTES = 32 KB)", () => { it("returns small blobs untouched and without a truncated flag", () => { const small = { hello: "world" }; expect(truncateLogData(small)).toEqual(small); }); it("stays within the cap and sets truncated:true on an over-cap blob", () => { // A single huge string pushes the JSON well past 32 KB. const big = { message: "x".repeat(LOG_DATA_MAX_BYTES * 4) }; const out = truncateLogData(big) as Record; expect(Buffer.byteLength(JSON.stringify(out), "utf8")).toBeLessThanOrEqual(LOG_DATA_MAX_BYTES); expect(out.truncated).toBe(true); }); it("never exceeds the cap even with many medium strings", () => { // Many ~2 KB strings: shortening each to 500 chars should bring it under cap. const many: Record = {}; for (let i = 0; i < 40; i++) many[`k${i}`] = "y".repeat(2048); const out = truncateLogData(many) as Record; expect(Buffer.byteLength(JSON.stringify(out), "utf8")).toBeLessThanOrEqual(LOG_DATA_MAX_BYTES); }); }); // --------------------------------------------------------------------------- // 4. automation.executed emission rule (depth-0 only) // --------------------------------------------------------------------------- describe("automation.executed emission (depth-0 only, no storm)", () => { const fakeRedis = () => { const xadd = vi.fn(async () => "1-1"); return { xadd, redis: { xadd } as never }; }; it("emits at depth 0 (human/API-originated)", async () => { const { redis, xadd } = fakeRedis(); const emitted = await emitAutomationExecuted({ redis, workspaceId: "ws_1", automationId: "auto_1", runId: "run_1", status: "SUCCEEDED", triggerType: "record.created", durationMs: 12, depth: 0 }); expect(emitted).toBe(true); expect(xadd).toHaveBeenCalledTimes(1); // xadd(stream, "*", "event", "automation.executed", "payload", json, "ts", iso) const args = xadd.mock.calls[0] as unknown[]; expect(args[0]).toBe("factory:events"); expect(args[2]).toBe("event"); expect(args[3]).toBe("automation.executed"); const payload = JSON.parse(args[5] as string); expect(payload).toMatchObject({ workspaceId: "ws_1", automationId: "auto_1", runId: "run_1", status: "SUCCEEDED", triggerType: "record.created" }); }); it("does NOT emit at depth ≥ 1 (chained runs)", async () => { for (const depth of [1, 4, 5, 99]) { const { redis, xadd } = fakeRedis(); const emitted = await emitAutomationExecuted({ redis, workspaceId: "ws_1", automationId: "auto_1", runId: `run_${depth}`, status: "FAILED", triggerType: "record.updated", durationMs: 5, depth }); expect(emitted, `depth ${depth} should not emit`).toBe(false); expect(xadd).not.toHaveBeenCalled(); } }); it("includes the error code on a failed terminal emit", async () => { const { redis, xadd } = fakeRedis(); await emitAutomationExecuted({ redis, workspaceId: "ws_1", automationId: "auto_1", runId: "run_e", status: "FAILED", triggerType: "schedule", durationMs: 3, error: { code: "ECONNREFUSED" }, depth: 0 }); const args = xadd.mock.calls[0] as unknown[]; const payload = JSON.parse(args[5] as string); expect(payload.error).toEqual({ code: "ECONNREFUSED" }); }); }); // --------------------------------------------------------------------------- // 5. Loop guard (self-trigger block + depth limit) // --------------------------------------------------------------------------- describe("loop guard (self-trigger + depth limit)", () => { it("allows a human/API event (no context) at depth 0", () => { expect(loopGuard(null, "auto_1")).toEqual({ blocked: false }); expect(loopGuard(undefined, "auto_1")).toEqual({ blocked: false }); }); it("blocks an automation reacting to its OWN event (self-trigger)", () => { const d = loopGuard({ automationId: "auto_1", depth: 0 }, "auto_1"); // full-object assertion: `reason` only exists on the blocked branch of // the LoopGuardDecision union (discriminated on `blocked`) expect(d).toEqual({ blocked: true, reason: "self_trigger", depth: 0 }); }); it("allows an event from a DIFFERENT automation below the depth cap", () => { const d = loopGuard({ automationId: "auto_OTHER", depth: 2 }, "auto_1"); expect(d.blocked).toBe(false); }); it("blocks at the depth limit (depth >= AUTOMATION_MAX_DEPTH)", () => { const d = loopGuard({ automationId: "auto_OTHER", depth: AUTOMATION_MAX_DEPTH }, "auto_1"); expect(d).toEqual({ blocked: true, reason: "depth_limit", depth: AUTOMATION_MAX_DEPTH }); // one above the cap is also blocked const d2 = loopGuard({ automationId: "auto_OTHER", depth: AUTOMATION_MAX_DEPTH + 1 }, "auto_1"); expect(d2).toEqual({ blocked: true, reason: "depth_limit", depth: AUTOMATION_MAX_DEPTH + 1 }); }); it("self-trigger wins over depth when both apply", () => { const d = loopGuard({ automationId: "auto_1", depth: AUTOMATION_MAX_DEPTH }, "auto_1"); expect(d).toEqual({ blocked: true, reason: "self_trigger", depth: AUTOMATION_MAX_DEPTH }); }); });