1
9import { describe, expect, it } from "vitest";
10import { createHmac } from "node:crypto";
11import {
12 AUTOMATION_MAX_DEPTH,
13 AUTOMATION_STATUS_TRANSITIONS,
14 buildWebhookSignature,
15 cronCheck,
16 dedupeKey,
17 emailCapKey,
18 generateWebhookSecret,
19 interpolate,
20 isValidAutomationTransition,
21 loopGuard,
22 normalizeConditionValue,
23 runBackoffSeconds,
24 truncateJson,
25 validateActionDto,
26 validateConditionTree,
27 validateTriggerConfig,
28 verifyWebhookSignature,
29 webhookSecretHash,
30 ActionConfigSchemas
31} from "./automation.js";
32
33
34
35
36
37describe("cronCheck", () => {
38 it("accepts standard 5-field expressions", () => {
39 for (const expr of ["* * * * *", "0 9 * * *", "*/5 * * * *", "0 0 * * 1", "30 4 1 1 *"]) {
40 const r = cronCheck(expr);
41 expect(r.ok, expr).toBe(true);
42 expect(r.nextFire).toBeInstanceOf(Date);
43 }
44 });
45
46 it("accepts 6-field (seconds) expressions at ≥ 60 s intervals", () => {
47 expect(cronCheck("0 * * * * *").ok).toBe(true);
48 expect(cronCheck("0 */30 * * * *").ok).toBe(true);
49 });
50
51 it("rejects 4-field expressions (CRON_INVALID)", () => {
52 expect(cronCheck("* * * *").code).toBe("CRON_INVALID");
53 });
54
55 it("accepts */30 minutes (5-field, 30-min interval)", () => {
56 expect(cronCheck("*/30 * * * *").ok).toBe(true);
57 });
58
59 it("rejects intervals < 60 s (CRON_TOO_FREQUENT)", () => {
60
61
62 for (const expr of ["*/30 * * * * *", "0,30 * * * * *", "* * * * * *"]) {
63 const r = cronCheck(expr);
64 expect(r.ok, expr).toBe(false);
65 expect(r.code, expr).toBe("CRON_TOO_FREQUENT");
66 }
67 });
68
69 it("rejects invalid expressions (CRON_INVALID)", () => {
70 for (const expr of ["", "not a cron", "* * * *", "61 * * * *", "*/0 * * * *", "a b c d e"]) {
71 const r = cronCheck(expr);
72 expect(r.ok, expr).toBe(false);
73 expect(r.code, expr).toBe("CRON_INVALID");
74 }
75 });
76});
77
78
79
80
81
82describe("automation status machine", () => {
83 it("accepts the 6 valid transitions", () => {
84 expect(isValidAutomationTransition("DRAFT", "ACTIVE")).toBe(true);
85 expect(isValidAutomationTransition("ACTIVE", "PAUSED")).toBe(true);
86 expect(isValidAutomationTransition("PAUSED", "ACTIVE")).toBe(true);
87 expect(isValidAutomationTransition("ACTIVE", "ARCHIVED")).toBe(true);
88 expect(isValidAutomationTransition("PAUSED", "ARCHIVED")).toBe(true);
89 expect(isValidAutomationTransition("ARCHIVED", "ACTIVE")).toBe(true);
90 });
91
92 it("rejects invalid pairs (incl. DRAFT→PAUSED/ARCHIVED, all no-ops)", () => {
93
94
95
96 expect(isValidAutomationTransition("DRAFT", "PAUSED")).toBe(false);
97 expect(isValidAutomationTransition("DRAFT", "ARCHIVED")).toBe(false);
98 expect(isValidAutomationTransition("ACTIVE", "DRAFT")).toBe(false);
99 expect(isValidAutomationTransition("PAUSED", "DRAFT")).toBe(false);
100 expect(isValidAutomationTransition("ARCHIVED", "PAUSED")).toBe(false);
101 expect(isValidAutomationTransition("ARCHIVED", "DRAFT")).toBe(false);
102
103 expect(isValidAutomationTransition("DRAFT", "DRAFT")).toBe(false);
104 expect(isValidAutomationTransition("ARCHIVED", "ARCHIVED")).toBe(false);
105 expect(isValidAutomationTransition("PAUSED", "PAUSED")).toBe(false);
106 expect(isValidAutomationTransition("ACTIVE", "ACTIVE")).toBe(false);
107 });
108
109 it("exposes the transition table", () => {
110 expect(AUTOMATION_STATUS_TRANSITIONS.ACTIVE).toEqual(expect.arrayContaining(["PAUSED", "ARCHIVED"]));
111 expect(AUTOMATION_STATUS_TRANSITIONS.ARCHIVED).toEqual(["ACTIVE"]);
112 });
113});
114
115
116
117
118
119describe("validateTriggerConfig", () => {
120 it("accepts valid configs per type", () => {
121 expect(validateTriggerConfig("RECORD_CREATED", { databaseId: "db_0000000000001" }).ok).toBe(true);
122 expect(validateTriggerConfig("RECORD_CREATED", {}).ok).toBe(true);
123 expect(validateTriggerConfig("PROPERTY_CHANGED", { properties: ["Priority"] }).ok).toBe(true);
124 expect(validateTriggerConfig("SCHEDULE", { cron: "0 9 * * *" }).ok).toBe(true);
125 expect(validateTriggerConfig("MANUAL", {}).ok).toBe(true);
126 expect(validateTriggerConfig("WEBHOOK", {}).ok).toBe(true);
127 });
128
129 it("rejects PROPERTY_CHANGED without properties", () => {
130 expect(validateTriggerConfig("PROPERTY_CHANGED", {}).ok).toBe(false);
131 });
132
133 it("rejects SCHEDULE with too-frequent cron (CRON_TOO_FREQUENT surfaced)", () => {
134 const r = validateTriggerConfig("SCHEDULE", { cron: "*/30 * * * * *" });
135 expect(r.ok).toBe(false);
136 });
137
138 it("rejects SCHEDULE with invalid cron", () => {
139 expect(validateTriggerConfig("SCHEDULE", { cron: "garbage" }).ok).toBe(false);
140 });
141
142 it("rejects unknown extra keys on strict schemas", () => {
143 expect(validateTriggerConfig("MANUAL", { foo: 1 }).ok).toBe(false);
144 });
145});
146
147
148
149
150
151const pred = (field: string, operator: string, value?: unknown): Record<string, unknown> =>
152 value === undefined ? { field: { ref: "property", name: field }, operator } : { field: { ref: "property", name: field }, operator, value };
153
154describe("validateConditionTree", () => {
155 it("accepts a bare predicate", () => {
156 expect(validateConditionTree(pred("Priority", "eq", "High")).ok).toBe(true);
157 });
158
159 it("accepts empty tree (always true)", () => {
160 expect(validateConditionTree(null).ok).toBe(true);
161 expect(validateConditionTree(undefined).ok).toBe(true);
162 });
163
164 it("accepts an AND of two predicates", () => {
165 const tree = { op: "AND", children: [pred("a", "eq", 1), pred("b", "is_empty")] };
166 expect(validateConditionTree(tree).ok).toBe(true);
167 });
168
169 it("requires value for non-empty operators", () => {
170 expect(validateConditionTree(pred("a", "eq")).ok).toBe(false);
171 expect(validateConditionTree(pred("a", "is_empty")).ok).toBe(true);
172 expect(validateConditionTree(pred("a", "is_not_empty")).ok).toBe(true);
173 });
174
175 it("rejects AND/OR with < 2 children and NOT with != 1", () => {
176 expect(validateConditionTree({ op: "AND", children: [pred("a", "is_empty")]}).ok).toBe(false);
177 expect(validateConditionTree({ op: "OR", children: [] }).ok).toBe(false);
178 expect(validateConditionTree({ op: "NOT", child: pred("a", "is_empty") }).ok).toBe(true);
179 expect(validateConditionTree({ op: "NOT" }).ok).toBe(false);
180 expect(validateConditionTree({ op: "NOT", children: [pred("a", "is_empty")] }).ok).toBe(false);
181 });
182
183 it("rejects unknown operators", () => {
184 expect(validateConditionTree({ field: { ref: "property", name: "a" }, operator: "like", value: "x" }).ok).toBe(false);
185 });
186
187 it("rejects depth 6 groups (depth 5 ok)", () => {
188 let depth5 = pred("a", "is_empty");
189 let depth6 = pred("a", "is_empty");
190 for (let i = 0; i < 5; i++) depth5 = { op: "NOT", child: depth5 };
191 for (let i = 0; i < 6; i++) depth6 = { op: "NOT", child: depth6 };
192 const r5 = validateConditionTree(depth5);
193 expect(r5.ok).toBe(true);
194 expect(r5.depth).toBe(5);
195 const r6 = validateConditionTree(depth6);
196 expect(r6.ok).toBe(false);
197 expect(r6.code).toBe("CONDITION_TOO_COMPLEX");
198 });
199
200 it("enforces the 100-node cap (wide trees, depth 2)", () => {
201
202
203
204 const leaf = () => pred("a", "is_empty");
205 const wide = (k: number): unknown => ({
206 op: "AND",
207 children: [
208 ...Array.from({ length: 8 }, () => ({ op: "AND", children: Array.from({ length: 10 }, leaf) })),
209 { op: "AND", children: Array.from({ length: k }, leaf) }
210 ]
211 });
212 const r100 = validateConditionTree(wide(10));
213 expect(r100.ok).toBe(true);
214 expect(r100.nodes).toBe(100);
215 const r101 = validateConditionTree(wide(11));
216 expect(r101.ok).toBe(false);
217 expect(r101.code).toBe("CONDITION_TOO_COMPLEX");
218 expect(r101.nodes).toBe(101);
219 });
220
221 it("rejects malformed nodes (missing field, bad ref)", () => {
222 expect(validateConditionTree({ operator: "eq", value: 1 }).ok).toBe(false);
223 expect(validateConditionTree({ field: { ref: "nope", name: "a" }, operator: "eq", value: 1 }).ok).toBe(false);
224 expect(validateConditionTree("just a string").ok).toBe(false);
225 });
226});
227
228describe("normalizeConditionValue", () => {
229 const opts = { options: [{ id: "o1", name: "High" }, { id: "o2", name: "Low" }] };
230 it("SELECT/STATUS → option name", () => {
231 expect(normalizeConditionValue("SELECT", opts, "o1")).toBe("High");
232 expect(normalizeConditionValue("STATUS", opts, "o2")).toBe("Low");
233 expect(normalizeConditionValue("SELECT", opts, null)).toBeNull();
234 });
235 it("MULTI_SELECT → sorted name array", () => {
236 expect(normalizeConditionValue("MULTI_SELECT", opts, ["o2", "o1"])).toEqual(["High", "Low"]);
237 });
238 it("PERSON → user-id array", () => {
239 expect(normalizeConditionValue("PERSON", null, ["u1", "u2"])).toEqual(["u1", "u2"]);
240 });
241 it("DATE range → start..end string", () => {
242 expect(normalizeConditionValue("DATE", null, { start: "2026-01-01", end: "2026-01-31" })).toBe("2026-01-01..2026-01-31");
243 });
244 it("NUMBER/CHECKBOX passthrough (typed)", () => {
245 expect(normalizeConditionValue("NUMBER", null, 42)).toBe(42);
246 expect(normalizeConditionValue("CHECKBOX", null, true)).toBe(true);
247 });
248});
249
250
251
252
253
254describe("validateActionDto", () => {
255 const base = { retries: 3, timeoutMs: 30_000 } as const;
256 const call = (type: string, config: Record<string, unknown>, extra: Record<string, unknown> = {}) =>
257 validateActionDto({ type: type as never, config, ...base, ...extra });
258
259 it("UPDATE_RECORD: valid + documented invalids", () => {
260 expect(call("UPDATE_RECORD", { recordId: "from_trigger", propertyValues: { Status: "In Progress" } }).ok).toBe(true);
261 expect(call("UPDATE_RECORD", { propertyValues: {} }).ok).toBe(false);
262 const twentyOne: Record<string, string> = Object.fromEntries(Array.from({ length: 21 }, (_, i) => [`p${i}`, "x"]));
263 expect(call("UPDATE_RECORD", { propertyValues: twentyOne }).ok).toBe(false);
264 });
265
266 it("SEND_WEBHOOK: http:// rejected (https only)", () => {
267 expect(call("SEND_WEBHOOK", { url: "http://example.com/hook" }).ok).toBe(false);
268 expect(call("SEND_WEBHOOK", { url: "https://example.com/hook" }).ok).toBe(true);
269 expect(call("SEND_WEBHOOK", { url: "https://example.com/hook", payload: Object.fromEntries(Array.from({ length: 70_000 }, (_, i) => [`k${i}`, "x"])) }).ok).toBe(false);
270 });
271
272 it("SEND_EMAIL: to must be email or token; cc ≤ 5", () => {
273 expect(call("SEND_EMAIL", { to: "a@b.co", subject: "s", body: "b" }).ok).toBe(true);
274 expect(call("SEND_EMAIL", { to: "not-an-email", subject: "s", body: "b" }).ok).toBe(false);
275 expect(call("SEND_EMAIL", { to: "a@b.co", subject: "s", body: "b", cc: ["1@x.co", "2@x.co", "3@x.co", "4@x.co", "5@x.co", "6@x.co"] }).ok).toBe(false);
276 });
277
278 it("CHANGE_STATUS / ASSIGN_USER shapes (propertyId per spec TASK-0112)", () => {
279 const prop = "cp12345678901234567890123456789";
280 expect(call("CHANGE_STATUS", { recordId: "from_trigger", propertyId: prop, value: "Done" }).ok).toBe(true);
281 expect(call("ASSIGN_USER", { propertyId: prop, userId: "trigger_user" }).ok).toBe(true);
282 expect(call("ASSIGN_USER", { propertyId: prop }).ok).toBe(false);
283 expect(call("ASSIGN_USER", { propertyId: "not-a-cuid", userId: "u1" }).ok).toBe(false);
284 });
285
286 it("CREATE_PAGE title 1..200 + interpolation depth 4 rejected", () => {
287 expect(call("CREATE_PAGE", { title: "Page {{record.Name}}" }).ok).toBe(true);
288 expect(call("CREATE_PAGE", { title: "x".repeat(201) }).ok).toBe(false);
289 expect(call("CREATE_PAGE", { title: "{{a.b.c.d}}" }).ok).toBe(false);
290 });
291
292 it("CREATE_NOTIFICATION link must be an internal path", () => {
293 expect(call("CREATE_NOTIFICATION", { to: "automation_creator", type: "AUTOMATION", body: "hi", link: "/w/x/db" }).ok).toBe(true);
294 expect(call("CREATE_NOTIFICATION", { to: "automation_creator", type: "AUTOMATION", body: "hi", link: "https://x.co" }).ok).toBe(false);
295 });
296
297 it("stubs validate config (CALL_INTEGRATION / RUN_AI_ACTION)", () => {
298 expect(call("CALL_INTEGRATION", { integrationId: "c1234567890abc", operation: "sync" }).ok).toBe(true);
299 expect(call("CALL_INTEGRATION", { integrationId: "x", operation: "sync" }).ok).toBe(false);
300 expect(call("RUN_AI_ACTION", { prompt: "sum {{record.Title}}", task: "summarize" }).ok).toBe(true);
301 expect(call("RUN_AI_ACTION", { prompt: "x", task: "nope" }).ok).toBe(false);
302 });
303
304 it("retries 1..8 + timeoutMs 1000..300000 enforced", () => {
305 expect(call("CREATE_PAGE", { title: "t" }, { retries: 0 }).ok).toBe(false);
306 expect(call("CREATE_PAGE", { title: "t" }, { retries: 9 }).ok).toBe(false);
307 expect(call("CREATE_PAGE", { title: "t" }, { retries: 1 }).ok).toBe(true);
308 expect(call("CREATE_PAGE", { title: "t" }, { retries: 8 }).ok).toBe(true);
309 expect(call("CREATE_PAGE", { title: "t" }, { timeoutMs: 500 }).ok).toBe(false);
310 expect(call("CREATE_PAGE", { title: "t" }, { timeoutMs: 300_001 }).ok).toBe(false);
311 expect(call("CREATE_PAGE", { title: "t" }, { timeoutMs: 300_000 }).ok).toBe(true);
312 });
313
314 it("unknown action type rejected", () => {
315 expect(call("SEND_SMS", {}).ok).toBe(false);
316 });
317
318 it("exposes the 9 config schemas", () => {
319 expect(Object.keys(ActionConfigSchemas)).toHaveLength(9);
320 });
321});
322
323
324
325
326
327describe("loopGuard", () => {
328 it("allows events without automation context (depth 0)", () => {
329 expect(loopGuard(null, "a1")).toEqual({ blocked: false });
330 expect(loopGuard({}, "a1")).toEqual({ blocked: false });
331 });
332
333 it("blocks self-trigger (same automationId)", () => {
334 expect(loopGuard({ automationId: "a1", depth: 0 }, "a1").blocked).toBe(true);
335 expect(loopGuard({ automationId: "a1", depth: 4 }, "a1")).toEqual({ blocked: true, reason: "self_trigger", depth: 4 });
336 });
337
338 it("allows other automations' events within the budget", () => {
339 expect(loopGuard({ automationId: "a2", depth: 1 }, "a1").blocked).toBe(false);
340 expect(loopGuard({ automationId: "a2", depth: 4 }, "a1").blocked).toBe(false);
341 });
342
343 it("blocks depth >= 5", () => {
344 expect(AUTOMATION_MAX_DEPTH).toBe(5);
345 expect(loopGuard({ automationId: "a2", depth: 5 }, "a1")).toEqual({ blocked: true, reason: "depth_limit", depth: 5 });
346 expect(loopGuard({ automationId: "a2", depth: 99 }, "a1")).toEqual({ blocked: true, reason: "depth_limit", depth: 99 });
347 });
348
349 it("self-trigger wins over depth (both true → self_trigger)", () => {
350 const d = loopGuard({ automationId: "a1", depth: 99 }, "a1");
351 expect(d.blocked).toBe(true);
352 if (d.blocked) expect(d.reason).toBe("self_trigger");
353 });
354});
355
356
357
358
359
360describe("webhook signature", () => {
361 const secret = generateWebhookSecret();
362 const body = JSON.stringify({ hello: "world" });
363
364 it("secret format: whsec_ + 64 hex", () => {
365 expect(secret).toMatch(/^whsec_[0-9a-f]{64}$/);
366 expect(webhookSecretHash(secret)).toMatch(/^[0-9a-f]{64}$/);
367 });
368
369 it("build → verify round-trip", () => {
370 const t = Math.floor(Date.now() / 1000);
371 const header = buildWebhookSignature(secret, t, body);
372 expect(verifyWebhookSignature(secret, header, body, t * 1000)).toBeNull();
373 });
374
375 it("tampered body → WEBHOOK_SIGNATURE_INVALID", () => {
376 const t = Math.floor(Date.now() / 1000);
377 const header = buildWebhookSignature(secret, t, body);
378 expect(verifyWebhookSignature(secret, header, body + "x", t * 1000)).toBe("WEBHOOK_SIGNATURE_INVALID");
379 });
380
381 it("stale timestamp (> 5 min) → WEBHOOK_TIMESTAMP_STALE", () => {
382 const t = Math.floor(Date.now() / 1000) - 10 * 60;
383 const header = buildWebhookSignature(secret, t, body);
384 expect(verifyWebhookSignature(secret, header, body, Date.now())).toBe("WEBHOOK_TIMESTAMP_STALE");
385 });
386
387 it("timestamp within ±5 min is accepted", () => {
388 const t = Math.floor(Date.now() / 1000) - 4 * 60;
389 const header = buildWebhookSignature(secret, t, body);
390 expect(verifyWebhookSignature(secret, header, body, Date.now())).toBeNull();
391 });
392
393 it("wrong secret → WEBHOOK_SIGNATURE_INVALID", () => {
394 const t = Math.floor(Date.now() / 1000);
395 const header = buildWebhookSignature(secret, t, body);
396 expect(verifyWebhookSignature(generateWebhookSecret(), header, body, t * 1000)).toBe("WEBHOOK_SIGNATURE_INVALID");
397 });
398
399 it("missing/malformed header → WEBHOOK_SIGNATURE_INVALID", () => {
400 expect(verifyWebhookSignature(secret, undefined, body)).toBe("WEBHOOK_SIGNATURE_INVALID");
401 expect(verifyWebhookSignature(secret, "garbage", body)).toBe("WEBHOOK_SIGNATURE_INVALID");
402 expect(verifyWebhookSignature(secret, "t=123,v1=zzz", body)).toBe("WEBHOOK_SIGNATURE_INVALID");
403 });
404
405 it("verification is over t.rawBody (not parsed JSON)", () => {
406 const t = Math.floor(Date.now() / 1000);
407 const raw = '{"a":1, "b": 2 }';
408 const expected = createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
409 const header = `t=${t},v1=${expected}`;
410 expect(verifyWebhookSignature(secret, header, raw, t * 1000)).toBeNull();
411 expect(verifyWebhookSignature(secret, header, '{"a":1,"b":2}', t * 1000)).toBe("WEBHOOK_SIGNATURE_INVALID");
412 });
413});
414
415
416
417
418
419describe("interpolate", () => {
420 const vars = {
421 record: { id: "r1", Title: "My Record", Priority: "High" },
422 event: { type: "record.created", payload: { category: "bug", deep: { x: 1 } } },
423 input: { note: "hello" },
424 automation: { id: "a1", name: "Auto" },
425 run: { id: "run1" }
426 };
427
428 it("resolves all 7 key families", () => {
429 const { value } = interpolate(
430 "{{record.Title}}|{{record.id}}|{{event.type}}|{{event.payload.category}}|{{input.note}}|{{automation.id}}|{{run.id}}",
431 vars
432 );
433 expect(value).toBe("My Record|r1|record.created|bug|hello|a1|run1");
434 });
435
436 it("unknown key → empty string + warning", () => {
437 const { value, warnings } = interpolate("{{missing.thing}}", vars);
438 expect(value).toBe("");
439 expect(warnings).toEqual(["unknown interpolation key: {{missing.thing}}"]);
440 });
441
442 it("no warnings for known families with missing leaves", () => {
443 const { value, warnings } = interpolate("[{{record.Absent}}]", vars);
444 expect(value).toBe("[]");
445 expect(warnings).toEqual([]);
446 });
447
448 it("object values JSON-stringified", () => {
449 const { value } = interpolate("{{record}}", vars);
450 expect(value).toBe(JSON.stringify(vars.record));
451 });
452
453 it("leaves non-token text intact", () => {
454 expect(interpolate("no tokens here", vars).value).toBe("no tokens here");
455 });
456});
457
458
459
460
461
462describe("truncateJson", () => {
463 it("passes small values through untouched", () => {
464 const r = truncateJson({ a: 1 }, 1024);
465 expect(r.truncated).toBe(false);
466 expect(r.value).toEqual({ a: 1 });
467 });
468
469 it("truncates oversized objects with a flag", () => {
470 const big = { small: 1, big: "x".repeat(10_000) };
471 const r = truncateJson(big, 512);
472 expect(r.truncated).toBe(true);
473 const obj = r.value as Record<string, unknown>;
474 expect(obj.truncated).toBe(true);
475 expect(obj.small).toBe(1);
476 expect(obj.big).toBe("[truncated]");
477 expect(new TextEncoder().encode(JSON.stringify(obj)).length).toBeLessThanOrEqual(512 + 64);
478 });
479
480 it("truncates oversized non-objects to a capped string", () => {
481 const r = truncateJson("y".repeat(10_000), 512);
482 expect(r.truncated).toBe(true);
483 expect(typeof r.value).toBe("string");
484 expect((r.value as string).endsWith("…[truncated]")).toBe(true);
485 expect(new TextEncoder().encode(r.value as string).length).toBeLessThanOrEqual(512);
486 });
487
488 it("handles null", () => {
489 expect(truncateJson(null, 16).value).toBeNull();
490 });
491});
492
493describe("runBackoffSeconds", () => {
494 it("schedule: 2^(n-1) capped at 600 s", () => {
495 const expected = [1, 2, 4, 8, 16, 32, 64, 128];
496 for (let n = 1; n <= 8; n++) expect(runBackoffSeconds(n)).toBe(expected[n - 1]);
497 expect(runBackoffSeconds(10)).toBe(512);
498 expect(runBackoffSeconds(11)).toBe(600);
499 expect(runBackoffSeconds(20)).toBe(600);
500 });
501
502 it("attempt 0 / invalid → 0", () => {
503 expect(runBackoffSeconds(0)).toBe(0);
504 expect(runBackoffSeconds(-3)).toBe(0);
505 });
506});
507
508describe("key helpers", () => {
509 it("dedupe key = auto:dedupe:{automationId}:{eventId}", () => {
510 expect(dedupeKey("a1", "e1")).toBe("auto:dedupe:a1:e1");
511 });
512 it("email cap key includes UTC date", () => {
513 expect(emailCapKey("ws1", new Date("2026-09-30T00:00:00Z"))).toBe("auto:email:ws1:2026-09-30");
514 });
515});
516