/** * PR P — Pluggable Rules Engine (gap-analysis v2 §5.2 partial). */ import { describe, it, expect, beforeEach } from "@jest/globals"; import { evaluate, evaluateCondition, getRuleSet, BUILTIN_PRECONDITIONS, BUILTIN_COMMIT, __resetRulesCacheForTests, type RuleSet, } from "../../src/services/rulesEngine"; describe("rulesEngine — primitive operators", () => { it("eq / neq / gt / gte / lt / lte", () => { expect( evaluateCondition({ path: "a", op: "eq", value: 1 }, { a: 1 }), ).toBe(true); expect( evaluateCondition({ path: "a", op: "neq", value: 1 }, { a: 2 }), ).toBe(true); expect( evaluateCondition({ path: "a", op: "gt", value: 1 }, { a: 2 }), ).toBe(true); expect( evaluateCondition({ path: "a", op: "lte", value: 3 }, { a: 3 }), ).toBe(true); }); it("in / not_in / exists / matches", () => { expect( evaluateCondition( { path: "role", op: "in", value: ["approver", "releaser"] }, { role: "approver" }, ), ).toBe(true); expect( evaluateCondition( { path: "role", op: "not_in", value: ["approver"] }, { role: "operator" }, ), ).toBe(true); expect( evaluateCondition({ path: "x", op: "exists" }, { x: 0 }), ).toBe(true); expect( evaluateCondition( { path: "hash", op: "matches", value: "^0x[0-9a-f]+$" }, { hash: "0xabc" }, ), ).toBe(true); }); it("length_gte / length_lte work on arrays and strings", () => { expect( evaluateCondition({ path: "a", op: "length_gte", value: 2 }, { a: [1, 2] }), ).toBe(true); expect( evaluateCondition({ path: "a", op: "length_lte", value: 5 }, { a: "abcd" }), ).toBe(true); }); it("dotted + indexed path resolution", () => { expect( evaluateCondition( { path: "plan.steps[1].type", op: "eq", value: "pay" }, { plan: { steps: [{ type: "issue" }, { type: "pay" }] } }, ), ).toBe(true); }); }); describe("rulesEngine — combinators", () => { const ctx = { role: "approver", amount: 1000 }; it("all (AND) — every child must pass", () => { expect( evaluateCondition( { all: [ { path: "role", op: "eq", value: "approver" }, { path: "amount", op: "gt", value: 500 }, ], }, ctx, ), ).toBe(true); expect( evaluateCondition( { all: [ { path: "role", op: "eq", value: "approver" }, { path: "amount", op: "gt", value: 5000 }, ], }, ctx, ), ).toBe(false); }); it("any (OR) — at least one child must pass", () => { expect( evaluateCondition( { any: [ { path: "role", op: "eq", value: "releaser" }, { path: "amount", op: "gt", value: 500 }, ], }, ctx, ), ).toBe(true); }); it("not — inverts the child", () => { expect( evaluateCondition( { not: { path: "role", op: "eq", value: "releaser" } }, ctx, ), ).toBe(true); }); }); describe("rulesEngine — evaluate() and failure reporting", () => { const ruleSet: RuleSet = { id: "test.rs", rules: [ { id: "amount_positive", description: "amount must be > 0", assert: { path: "amount", op: "gt", value: 0 }, }, { id: "role_listed", description: "role must be in the allowed list", assert: { path: "role", op: "in", value: ["approver", "releaser", "operator"], }, }, { id: "warning_only", description: "low amount warning", severity: "warn", assert: { path: "amount", op: "gte", value: 10_000 }, }, ], }; it("returns ok=true when all error-severity rules pass", () => { const res = evaluate(ruleSet, { amount: 1000, role: "approver" }); expect(res.ok).toBe(true); // warn still reported even though ok=true expect(res.failures.some((f) => f.ruleId === "warning_only")).toBe(true); expect(res.failures.every((f) => f.severity === "warn")).toBe(true); }); it("returns ok=false with error failure when a blocking rule fails", () => { const res = evaluate(ruleSet, { amount: -1, role: "approver" }); expect(res.ok).toBe(false); const amountFail = res.failures.find((f) => f.ruleId === "amount_positive"); expect(amountFail?.severity).toBe("error"); }); it("'when' gates a rule — false when-clause skips the assert", () => { const guarded: RuleSet = { id: "guarded.rs", rules: [ { id: "kyc_if_present", when: { path: "compliance", op: "exists" }, assert: { path: "compliance.kyc", op: "eq", value: "ok" }, }, ], }; expect(evaluate(guarded, {}).ok).toBe(true); expect(evaluate(guarded, { compliance: { kyc: "ok" } }).ok).toBe(true); expect(evaluate(guarded, { compliance: { kyc: "fail" } }).ok).toBe(false); }); }); describe("rulesEngine — built-in rule sets", () => { it("preconditions: pay step + non-empty participants passes", () => { const res = evaluate(BUILTIN_PRECONDITIONS, { plan: { steps: [{ type: "pay" }] }, participants: [{ id: "p1" }], }); expect(res.ok).toBe(true); }); it("preconditions: missing pay step fails", () => { const res = evaluate(BUILTIN_PRECONDITIONS, { plan: { steps: [{ type: "issueInstrument" }] }, participants: [{ id: "p1" }], }); expect(res.ok).toBe(false); expect(res.failures.some((f) => f.ruleId === "plan.pay_step_present")).toBe( true, ); }); it("commit: VALIDATING + matching refs + no exceptions passes", () => { const res = evaluate(BUILTIN_COMMIT, { state: "VALIDATING", dlt: { txHash: `0x${"a".repeat(64)}` }, bank: { isoMessageId: "MSG-1" }, exceptions: { active: [] }, }); expect(res.ok).toBe(true); }); it("commit: state != VALIDATING blocks", () => { const res = evaluate(BUILTIN_COMMIT, { state: "EXECUTING", dlt: { txHash: `0x${"a".repeat(64)}` }, bank: { isoMessageId: "MSG-1" }, exceptions: { active: [] }, }); expect(res.ok).toBe(false); expect(res.failures.some((f) => f.ruleId === "state.is_validating")).toBe( true, ); }); }); describe("rulesEngine — pluggable loading", () => { beforeEach(() => { __resetRulesCacheForTests(); delete process.env.RULES_FILE; }); it("returns built-ins when RULES_FILE is unset", () => { expect(getRuleSet(BUILTIN_PRECONDITIONS.id).rules.length).toBeGreaterThan(0); expect(getRuleSet(BUILTIN_COMMIT.id).rules.length).toBeGreaterThan(0); }); it("returns an empty rule set for unknown ids (no throw)", () => { const rs = getRuleSet("nonexistent"); expect(rs.rules).toEqual([]); }); });