1
10import { describe, expect, it } from "vitest";
11import {
12 CONDITION_EVAL_BUDGET_MS,
13 ConditionError,
14 evaluateCondition,
15 unwrapEnvelope,
16 type ConditionContext
17} from "../condition-evaluator.js";
18
19const ctx = (event: Record<string, unknown> = {}, record: Record<string, unknown> | null = null): ConditionContext => ({
20 event,
21 record,
22 page: null
23});
24
25const pred = (ref: "event" | "property", name: string, operator: string, value?: unknown) => ({
26 field: { ref, name },
27 operator,
28 ...(value === undefined ? {} : { value })
29});
30
31
32
33
34describe("eq / neq", () => {
35 it("string equality (case-sensitive)", () => {
36 expect(evaluateCondition(pred("property", "Status", "eq", "Done"), ctx({}, { Status: "Done" })).result).toBe(true);
37 expect(evaluateCondition(pred("property", "Status", "eq", "done"), ctx({}, { Status: "Done" })).result).toBe(false);
38 });
39 it("numeric equality incl. string-number", () => {
40 expect(evaluateCondition(pred("property", "N", "eq", 5), ctx({}, { N: 5 })).result).toBe(true);
41 expect(evaluateCondition(pred("property", "N", "eq", "5"), ctx({}, { N: 5 })).result).toBe(true);
42 expect(evaluateCondition(pred("property", "N", "eq", 4), ctx({}, { N: 5 })).result).toBe(false);
43 });
44 it("boolean equality", () => {
45 expect(evaluateCondition(pred("property", "Flag", "eq", true), ctx({}, { Flag: true })).result).toBe(true);
46 expect(evaluateCondition(pred("property", "Flag", "eq", false), ctx({}, { Flag: true })).result).toBe(false);
47 });
48 it("date (ISO string) equality", () => {
49 expect(evaluateCondition(pred("property", "Due", "eq", "2026-09-30"), ctx({}, { Due: "2026-09-30" })).result).toBe(true);
50 expect(evaluateCondition(pred("property", "Due", "eq", "2026-09-31"), ctx({}, { Due: "2026-09-30" })).result).toBe(false);
51 });
52 it("multi-select: SET equality (order-insensitive), not bag", () => {
53 expect(evaluateCondition(pred("property", "Tags", "eq", ["b", "a"]), ctx({}, { Tags: ["a", "b"] })).result).toBe(true);
54 expect(evaluateCondition(pred("property", "Tags", "eq", ["a"]), ctx({}, { Tags: ["a", "b"] })).result).toBe(false);
55 expect(evaluateCondition(pred("property", "Tags", "eq", ["a", "a"]), ctx({}, { Tags: ["a"] })).result).toBe(false);
56 });
57 it("null/missing: present null eq null → true; absent → false (fail closed)", () => {
58 expect(evaluateCondition(pred("property", "X", "eq", null), ctx({}, { X: null })).result).toBe(true);
59 expect(evaluateCondition(pred("property", "X", "eq", null), ctx({}, {})).result).toBe(false);
60 });
61 it("neq is the negation of eq (absent → false, fail closed)", () => {
62 expect(evaluateCondition(pred("property", "X", "neq", "a"), ctx({}, { X: "b" })).result).toBe(true);
63 expect(evaluateCondition(pred("property", "X", "neq", "a"), ctx({}, { X: "a" })).result).toBe(false);
64 expect(evaluateCondition(pred("property", "X", "neq", "a"), ctx({}, {})).result).toBe(false);
65 });
66 it("type mismatch (number vs non-numeric string) → false", () => {
67 expect(evaluateCondition(pred("property", "N", "eq", "not-a-number"), ctx({}, { N: 5 })).result).toBe(false);
68 });
69});
70
71
72
73
74describe("contains / not_contains / starts_with / ends_with", () => {
75 it("contains: string substring, case-insensitive", () => {
76 expect(evaluateCondition(pred("property", "T", "contains", "lo"), ctx({}, { T: "Hello World" })).result).toBe(true);
77 expect(evaluateCondition(pred("property", "T", "contains", "xyz"), ctx({}, { T: "Hello" })).result).toBe(false);
78 });
79 it("contains: array membership", () => {
80 expect(evaluateCondition(pred("property", "Tags", "contains", "a"), ctx({}, { Tags: ["a", "b"] })).result).toBe(true);
81 expect(evaluateCondition(pred("property", "Tags", "contains", "z"), ctx({}, { Tags: ["a", "b"] })).result).toBe(false);
82 });
83 it("not_contains: negation", () => {
84 expect(evaluateCondition(pred("property", "T", "not_contains", "xyz"), ctx({}, { T: "Hello" })).result).toBe(true);
85 expect(evaluateCondition(pred("property", "T", "not_contains", "lo"), ctx({}, { T: "Hello" })).result).toBe(false);
86 });
87 it("starts_with / ends_with: case-insensitive", () => {
88 expect(evaluateCondition(pred("property", "T", "starts_with", "he"), ctx({}, { T: "Hello" })).result).toBe(true);
89 expect(evaluateCondition(pred("property", "T", "starts_with", "world"), ctx({}, { T: "Hello" })).result).toBe(false);
90 expect(evaluateCondition(pred("property", "T", "ends_with", "LLO"), ctx({}, { T: "Hello" })).result).toBe(true);
91 });
92 it("string ops on non-strings fail closed (false + warning)", () => {
93 const d = evaluateCondition(pred("property", "N", "starts_with", "5"), ctx({}, { N: 5 }));
94 expect(d.result).toBe(false);
95 expect(d.warnings.length).toBeGreaterThan(0);
96 });
97 it("absent field → false for all four", () => {
98 for (const op of ["contains", "not_contains", "starts_with", "ends_with"]) {
99 const d = evaluateCondition(pred("property", "X", op, "a"), ctx({}, {}));
100 expect(d.result).toBe(false);
101 expect(d.warnings.length).toBeGreaterThan(0);
102 }
103 });
104});
105
106
107
108
109describe("gt / gte / lt / lte", () => {
110 it("numbers", () => {
111 const c = ctx({}, { N: 10 });
112 expect(evaluateCondition(pred("property", "N", "gt", 5), c).result).toBe(true);
113 expect(evaluateCondition(pred("property", "N", "gt", 10), c).result).toBe(false);
114 expect(evaluateCondition(pred("property", "N", "gte", 10), c).result).toBe(true);
115 expect(evaluateCondition(pred("property", "N", "lt", 10), c).result).toBe(false);
116 expect(evaluateCondition(pred("property", "N", "lte", 10), c).result).toBe(true);
117 });
118 it("dates (ISO strings) compare chronologically", () => {
119 const c = ctx({}, { Due: "2026-09-30T00:00:00.000Z" });
120 expect(evaluateCondition(pred("property", "Due", "gt", "2026-09-01"), c).result).toBe(true);
121 expect(evaluateCondition(pred("property", "Due", "lt", "2026-10-01"), c).result).toBe(true);
122 expect(evaluateCondition(pred("property", "Due", "gt", "2026-10-01"), c).result).toBe(false);
123 });
124 it("non-comparable (string vs string-date) → false + warn (no crash)", () => {
125 const d = evaluateCondition(pred("property", "T", "gt", "2026-01-01"), ctx({}, { T: "hello" }));
126 expect(d.result).toBe(false);
127 expect(d.warnings.length).toBeGreaterThan(0);
128 });
129 it("absent field → false + warn", () => {
130 const d = evaluateCondition(pred("property", "X", "gt", 0), ctx({}, {}));
131 expect(d.result).toBe(false);
132 expect(d.warnings.length).toBeGreaterThan(0);
133 });
134});
135
136
137
138
139describe("in / not_in", () => {
140 it("scalar membership", () => {
141 const c = ctx({}, { Status: "bug" });
142 expect(evaluateCondition(pred("property", "Status", "in", ["bug", "feature"]), c).result).toBe(true);
143 expect(evaluateCondition(pred("property", "Status", "in", ["bug"]), c).result).toBe(true);
144 expect(evaluateCondition(pred("property", "Status", "in", ["x", "y"]), c).result).toBe(false);
145 expect(evaluateCondition(pred("property", "Status", "not_in", ["x", "y"]), c).result).toBe(true);
146 expect(evaluateCondition(pred("property", "Status", "not_in", ["bug"]), c).result).toBe(false);
147 });
148 it("multi-select: in = intersection non-empty", () => {
149 const c = ctx({}, { Tags: ["a", "b"] });
150 expect(evaluateCondition(pred("property", "Tags", "in", ["z", "b"]), c).result).toBe(true);
151 expect(evaluateCondition(pred("property", "Tags", "in", ["z"]), c).result).toBe(false);
152 expect(evaluateCondition(pred("property", "Tags", "not_in", ["z"]), c).result).toBe(true);
153 expect(evaluateCondition(pred("property", "Tags", "not_in", ["b"]), c).result).toBe(false);
154 });
155});
156
157
158
159
160describe("is_empty / is_not_empty", () => {
161 it("null / empty-string / empty-array / empty-object are empty", () => {
162 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: null })).result).toBe(true);
163 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: "" })).result).toBe(true);
164 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: [] })).result).toBe(true);
165 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: {} })).result).toBe(true);
166 });
167 it("non-empty values", () => {
168 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: "a" })).result).toBe(false);
169 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: 0 })).result).toBe(false);
170 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, { X: false })).result).toBe(false);
171 });
172 it("absent value: is_empty → true; is_not_empty → false", () => {
173 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, {})).result).toBe(true);
174 expect(evaluateCondition(pred("property", "X", "is_not_empty", undefined), ctx({}, {})).result).toBe(false);
175 });
176 it("is_not_empty on present values", () => {
177 expect(evaluateCondition(pred("property", "X", "is_not_empty", undefined), ctx({}, { X: "a" })).result).toBe(true);
178 expect(evaluateCondition(pred("property", "X", "is_not_empty", undefined), ctx({}, { X: [] })).result).toBe(false);
179 });
180});
181
182
183
184
185describe("event-ref fields", () => {
186 it("event.type / event.databaseId / event.workspaceId", () => {
187 const c = ctx({ type: "record_created", databaseId: "db1", workspaceId: "w1" }, { N: 1 });
188 expect(evaluateCondition(pred("event", "type", "eq", "record_created"), c).result).toBe(true);
189 expect(evaluateCondition(pred("event", "databaseId", "eq", "db1"), c).result).toBe(true);
190 expect(evaluateCondition(pred("event", "workspaceId", "neq", "other"), c).result).toBe(true);
191 });
192 it("event.changedProperties (array) membership", () => {
193 const c = ctx({ changedProperties: ["Priority"] }, {});
194 expect(evaluateCondition(pred("event", "changedProperties", "contains", "Priority"), c).result).toBe(true);
195 expect(evaluateCondition(pred("event", "changedProperties", "contains", "Status"), c).result).toBe(false);
196 });
197 it("dot-path into payload (WEBHOOK shape): event.payload.category in […]", () => {
198 const c = ctx({ type: "webhook", payload: { category: "bug" } }, null);
199 expect(evaluateCondition(pred("event", "payload.category", "in", ["bug", "feature"]), c).result).toBe(true);
200 expect(evaluateCondition(pred("event", "payload.category", "in", ["ops"]), c).result).toBe(false);
201 });
202 it("FORM_SUBMITTED: event.payload.anonymous eq true", () => {
203 const c = ctx({ type: "form_submitted", payload: { anonymous: true } }, null);
204 expect(evaluateCondition(pred("event", "payload.anonymous", "eq", true), c).result).toBe(true);
205 expect(evaluateCondition(pred("event", "payload.anonymous", "eq", false), c).result).toBe(false);
206 });
207 it("payload.* deeper than 3 segments → undefined → fail closed", () => {
208 const c = ctx({ payload: { a: { b: { c: { d: 1 } } } } }, null);
209 expect(evaluateCondition(pred("event", "payload.a.b.c.d", "eq", 1), c).result).toBe(false);
210 });
211 it("missing event field → false (fail closed)", () => {
212 const d = evaluateCondition(pred("event", "type", "eq", "x"), ctx({}));
213 expect(d.result).toBe(false);
214 expect(d.warnings.length).toBeGreaterThan(0);
215 });
216});
217
218
219
220
221describe("property fail-closed", () => {
222 it("no record → property.* predicate false (even eq null)", () => {
223 const d = evaluateCondition(pred("property", "Priority", "eq", "High"), ctx({}, null));
224 expect(d.result).toBe(false);
225 expect(d.warnings.length).toBeGreaterThan(0);
226 });
227 it("no record: AND with an always-true event predicate → overall false", () => {
228 const tree = {
229 op: "AND",
230 children: [pred("event", "type", "eq", "record_created"), pred("property", "Priority", "eq", "High")]
231 };
232 expect(evaluateCondition(tree, ctx({ type: "record_created" }, null)).result).toBe(false);
233 });
234 it("unknown property name → false (no crash)", () => {
235 const d = evaluateCondition(pred("property", "Nope", "eq", "x"), ctx({}, { Status: "Done" }));
236 expect(d.result).toBe(false);
237 expect(d.warnings.length).toBeGreaterThan(0);
238 });
239 it("no record: is_empty on a property → true (absent value semantics)", () => {
240 expect(evaluateCondition(pred("property", "X", "is_empty", undefined), ctx({}, null)).result).toBe(true);
241 });
242});
243
244
245
246
247describe("tree structure", () => {
248 it("AND short-circuits (first false → overall false)", () => {
249 const t = { op: "AND", children: [pred("property", "A", "eq", "1"), pred("property", "A", "eq", "2")] };
250 expect(evaluateCondition(t, ctx({}, { A: "1" })).result).toBe(false);
251 });
252 it("OR short-circuits (first true → overall true)", () => {
253 const t = { op: "OR", children: [pred("property", "A", "eq", "1"), pred("property", "A", "eq", "2")] };
254 expect(evaluateCondition(t, ctx({}, { A: "1" })).result).toBe(true);
255 });
256 it("NOT negates", () => {
257 const t = { op: "NOT", child: pred("property", "Status", "eq", "Done") };
258 expect(evaluateCondition(t, ctx({}, { Status: "Done" })).result).toBe(false);
259 expect(evaluateCondition(t, ctx({}, { Status: "Open" })).result).toBe(true);
260 });
261 it("nested AND/OR/NOT at depth 5 is accepted", () => {
262 const d5 = {
263 op: "AND" as const,
264 children: [
265 { op: "OR", children: [{ op: "NOT", child: pred("property", "A", "eq", "1") }, pred("property", "A", "eq", "1")] },
266 pred("property", "B", "is_empty", undefined)
267 ]
268 };
269
270 expect(() => evaluateCondition(d5, ctx({}, { A: "1", B: null }))).not.toThrow();
271 });
272 it("depth 6 group nesting → CONDITION_INVALID (over depth)", () => {
273 let node: unknown = pred("property", "A", "eq", "1");
274 for (let i = 0; i < 6; i++) node = { op: "AND", children: [node, pred("property", "B", "is_empty", undefined)] };
275 expect(() => evaluateCondition(node, ctx({}, { A: "1" }))).toThrowError(ConditionError);
276 try {
277 evaluateCondition(node, ctx({}, { A: "1" }));
278 } catch (e) {
279 expect((e as ConditionError).code).toBe("CONDITION_INVALID");
280 }
281 });
282 it("100 nodes accepted, 101 rejected", () => {
283
284 const leaves100 = Array.from({ length: 100 }, (_, i) => pred("property", `P${i}`, "is_empty", undefined));
285 const exactly100 = { op: "AND", children: [leaves100.slice(0, 99), leaves100[99]!] };
286
287 expect(() => evaluateCondition(exactly100, ctx({}))).toThrowError(ConditionError);
288
289 const ok100 = { op: "AND", children: leaves100.slice(0, 99) };
290 expect(() => evaluateCondition(ok100, ctx({}))).not.toThrow();
291 });
292 it("AND with 1 child / NOT without child → CONDITION_INVALID", () => {
293 expect(() => evaluateCondition({ op: "AND", children: [pred("property", "A", "eq", "1")] }, ctx({}))).toThrowError(ConditionError);
294 expect(() => evaluateCondition({ op: "NOT" }, ctx({}))).toThrowError(ConditionError);
295 });
296 it("null/undefined tree → always-true (evaluatedNodes 0)", () => {
297 expect(evaluateCondition(null, ctx({}))).toEqual({ result: true, evaluatedNodes: 0, ms: 0, warnings: [] });
298 expect(evaluateCondition(undefined, ctx({})).result).toBe(true);
299 });
300 it("evaluatedNodes counts every visited node", () => {
301 const t = { op: "AND", children: [pred("property", "A", "eq", "1"), pred("property", "B", "eq", "2")] };
302 expect(evaluateCondition(t, ctx({}, { A: "1", B: "2" })).evaluatedNodes).toBe(3);
303 });
304});
305
306
307
308
309describe("timeout budget", () => {
310 it("throws CONDITION_TIMEOUT when the clock advances past the budget", () => {
311
312 const t = { op: "AND", children: [pred("property", "A", "eq", "1"), pred("property", "B", "eq", "2")] };
313 let clock = 0;
314 expect(() =>
315 evaluateCondition(t, ctx({}, { A: "1", B: "2" }), () => {
316 clock += CONDITION_EVAL_BUDGET_MS + 1;
317 return clock;
318 })
319 ).toThrowError(ConditionError);
320 try {
321 evaluateCondition(t, ctx({}, { A: "1", B: "2" }), () => {
322 clock += CONDITION_EVAL_BUDGET_MS + 1;
323 return clock;
324 });
325 } catch (e) {
326 expect((e as ConditionError).code).toBe("CONDITION_TIMEOUT");
327 }
328 });
329 it("deterministic: same ctx → same result (run 100×)", () => {
330 const t = {
331 op: "OR",
332 children: [
333 { op: "AND", children: [pred("property", "N", "gt", 10), pred("property", "Status", "in", ["a", "b"])] },
334 { op: "NOT", child: pred("property", "Flag", "is_empty", undefined) }
335 ]
336 };
337 const c = ctx({}, { N: 20, Status: "a", Flag: true });
338 const first = evaluateCondition(t, c);
339 for (let i = 0; i < 100; i++) expect(evaluateCondition(t, c).result).toBe(first.result);
340 });
341});
342
343
344
345
346describe("unwrapEnvelope", () => {
347 it("unwraps each Phase-5 stored shape", () => {
348 expect(unwrapEnvelope("TEXT", { text: "hi", marks: [] })).toBe("hi");
349 expect(unwrapEnvelope("NUMBER", { value: 3 })).toBe(3);
350 expect(unwrapEnvelope("SELECT", { optionId: "opt1" })).toBe("opt1");
351 expect(unwrapEnvelope("MULTI_SELECT", { optionIds: ["a", "b"] })).toEqual(["a", "b"]);
352 expect(unwrapEnvelope("PERSON", { userIds: ["u1"] })).toEqual(["u1"]);
353 expect(unwrapEnvelope("DATE", { start: "2026-01-01", end: null })).toBe("2026-01-01");
354 expect(unwrapEnvelope("DATE_RANGE", { start: "2026-01-01", end: "2026-01-02" })).toBe("2026-01-01..2026-01-02");
355 expect(unwrapEnvelope("CHECKBOX", { value: true })).toBe(true);
356 expect(unwrapEnvelope("URL", { value: "https://x", title: null })).toBe("https://x");
357
358 expect(unwrapEnvelope("TITLE", "plain")).toBe("plain");
359 expect(unwrapEnvelope("UNKNOWN", { foo: 1 })).toEqual({ foo: 1 });
360 expect(unwrapEnvelope("TEXT", null)).toBeNull();
361 });
362});
363