|
1 | 1 | // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { describe, it, expect } from 'vitest'; |
| 4 | +import { TimeRelativeTriggerSchema } from '@objectstack/spec/automation'; |
4 | 5 | import { |
5 | 6 | validateFlowTriggerReadiness, |
6 | 7 | FLOW_TRIGGER_UNKNOWN_OBJECT, |
7 | 8 | FLOW_DRAFT_STATUS_AMBIGUOUS, |
8 | 9 | FLOW_TRIGGER_UNKNOWN_EVENT, |
| 10 | + FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, |
9 | 11 | } from './validate-flow-trigger-readiness.js'; |
10 | 12 |
|
11 | 13 | function recordFlow(overrides: Record<string, unknown> = {}) { |
@@ -174,6 +176,207 @@ describe('validateFlowTriggerReadiness', () => { |
174 | 176 | expect(findings[0].rule).toBe(FLOW_TRIGGER_UNKNOWN_OBJECT); |
175 | 177 | expect(findings[0].message).toContain("'contract'"); |
176 | 178 | expect(findings[0].path).toBe('flows[0].nodes[0].config.timeRelative.object'); |
| 179 | + // The SHAPE is canonical, so the descriptor rule stays out of it — the two |
| 180 | + // halves of 1b decide different facts and must not both fire on one. |
| 181 | + expect(TimeRelativeTriggerSchema.safeParse({ |
| 182 | + object: 'contract', dateField: 'end_date', withinDays: 60, |
| 183 | + }).success).toBe(true); |
| 184 | + expect(findings.some((f) => f.rule === FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID)).toBe(false); |
| 185 | + }); |
| 186 | + |
| 187 | + // ── #5496 — the descriptor's SHAPE ──────────────────────────────────────── |
| 188 | + // |
| 189 | + // `TimeRelativeTriggerSchema` is the only thing that can judge a |
| 190 | + // `config.timeRelative` descriptor (the node `config` slot is open by design, |
| 191 | + // ADR-0018, so no outer flow gate sees inside it), and until this rule the only |
| 192 | + // place it ran was BIND time — one warn in a server log, nothing in |
| 193 | + // `os validate`. These tests pin the forwarding, not a second copy of the |
| 194 | + // shape: where a message is asserted it is asserted against what the schema |
| 195 | + // itself produces, so the rule cannot drift from the contract it speaks for. |
| 196 | + describe('config.timeRelative descriptor shape (#5496)', () => { |
| 197 | + /** The stack from the issue: `task` EXISTS, flow is active and runs as system. */ |
| 198 | + function timeRelativeStack(timeRelative: unknown, objectName = 'task') { |
| 199 | + return { |
| 200 | + objects: [{ name: objectName, label: 'Task', fields: {} }], |
| 201 | + flows: [ |
| 202 | + { |
| 203 | + name: 'task_due_reminder', |
| 204 | + type: 'schedule', |
| 205 | + status: 'active', |
| 206 | + runAs: 'system', |
| 207 | + nodes: [ |
| 208 | + { id: 'start', type: 'start', config: { timeRelative } }, |
| 209 | + { id: 'end', type: 'end' }, |
| 210 | + ], |
| 211 | + edges: [{ id: 'e1', source: 'start', target: 'end' }], |
| 212 | + }, |
| 213 | + ], |
| 214 | + }; |
| 215 | + } |
| 216 | + |
| 217 | + /** The exact descriptor #5496 was filed for — three separate zod issues. */ |
| 218 | + const badDescriptor = { object: 'task', field: 'due_at', offsetDays: -1 }; |
| 219 | + |
| 220 | + it('flags the descriptor from #5496 and names every key zod named', () => { |
| 221 | + const findings = validateFlowTriggerReadiness(timeRelativeStack(badDescriptor)); |
| 222 | + expect(findings).toHaveLength(1); |
| 223 | + const [f] = findings; |
| 224 | + expect(f.rule).toBe(FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID); |
| 225 | + expect(f.severity).toBe('warning'); |
| 226 | + // Criterion 1: the finding NAMES config.timeRelative, in both channels the |
| 227 | + // CLI prints (`• where: message` then `at path`). |
| 228 | + expect(f.path).toBe('flows[0].nodes[0].config.timeRelative'); |
| 229 | + expect(f.message).toContain('config.timeRelative'); |
| 230 | + expect(f.where).toBe('flow "task_due_reminder" › start node'); |
| 231 | + // …and carries zod's own key names: the missing `dateField`, the scalar |
| 232 | + // `offsetDays`, and the unrecognized `field` with the schema's suggestion. |
| 233 | + expect(f.message).toContain('dateField: Invalid input: expected string, received undefined'); |
| 234 | + expect(f.message).toContain('offsetDays: Invalid input: expected array, received number'); |
| 235 | + expect(f.message).toContain('Unrecognized key(s)'); |
| 236 | + expect(f.message).toContain('`field`'); |
| 237 | + expect(f.message).toContain('Did you mean `field` → `dateField`?'); |
| 238 | + // The consequence, which is the whole reason this is not just a log line. |
| 239 | + expect(f.message).toMatch(/never runs/); |
| 240 | + expect(f.hint).toContain('TimeRelativeTriggerSchema'); |
| 241 | + }); |
| 242 | + |
| 243 | + it('forwards the schema verbatim rather than restating it (anti-drift pin)', () => { |
| 244 | + // Every problem segment is `TimeRelativeTriggerSchema`'s own text, rendered |
| 245 | + // the way `TimeRelativeTrigger.start()` renders the identical issue list at |
| 246 | + // bind time. Derived from the schema HERE too, so this assertion tracks the |
| 247 | + // contract instead of freezing today's wording: if the schema's message for |
| 248 | + // a rejected descriptor changes, the rule's output changes with it and this |
| 249 | + // test keeps passing — but a hand-written copy in the rule would not. |
| 250 | + const parsed = TimeRelativeTriggerSchema.safeParse(badDescriptor); |
| 251 | + expect(parsed.success).toBe(false); |
| 252 | + const expected = parsed.error!.issues |
| 253 | + .map((i) => `${i.path.join('.') || '(root)'}: ${i.message.replace(/\s+/g, ' ').trim()}`) |
| 254 | + .join('; '); |
| 255 | + const [f] = validateFlowTriggerReadiness(timeRelativeStack(badDescriptor)); |
| 256 | + expect(f.message).toContain(expected); |
| 257 | + // Single-line, so the CLI's bulleted list stays aligned (the schema's |
| 258 | + // guidance bullets arrive with newlines in them). |
| 259 | + expect(f.message).not.toContain('\n'); |
| 260 | + expect(f.hint).not.toContain('\n'); |
| 261 | + }); |
| 262 | + |
| 263 | + it('stays silent on the canonical descriptors — including the ones shipped in the repo', () => { |
| 264 | + // Criterion 2. Each is pinned against the schema as well as against the |
| 265 | + // rule, so a fixture cannot rot into an unbindable descriptor and keep this |
| 266 | + // test green for the wrong reason (#4966's lesson, one layer down). |
| 267 | + const canonical: Array<[string, Record<string, unknown>]> = [ |
| 268 | + ['#5496 acceptance shape', { object: 'task', dateField: 'due_at', offsetDays: [-1] }], |
| 269 | + // examples/app-showcase `Task Due Reminder` (#1874) — the showcase flow |
| 270 | + // criterion 2 names by hand. |
| 271 | + ['showcase Task Due Reminder', { |
| 272 | + object: 'task', |
| 273 | + dateField: 'due_date', |
| 274 | + offsetDays: [3, 1], |
| 275 | + filter: { status: { $ne: 'done' } }, |
| 276 | + }], |
| 277 | + // content/docs/references/automation/time-relative-trigger.mdx, all three |
| 278 | + // examples, and content/docs/automation/flows.mdx's `renewalReminder`. |
| 279 | + ['docs T-minus example', { |
| 280 | + object: 'task', dateField: 'end_date', offsetDays: [60, 30, 7], filter: { status: 'active' }, |
| 281 | + }], |
| 282 | + ['docs expiring-soon example', { object: 'task', dateField: 'expires_on', withinDays: 30 }], |
| 283 | + ['docs overdue example', { |
| 284 | + object: 'task', dateField: 'due_date', withinDays: -14, filter: { status: 'open' }, |
| 285 | + }], |
| 286 | + ['with maxRecords', { object: 'task', dateField: 'due_at', withinDays: 7, maxRecords: 50 }], |
| 287 | + ]; |
| 288 | + for (const [label, descriptor] of canonical) { |
| 289 | + expect(TimeRelativeTriggerSchema.safeParse(descriptor).success, `${label} must be spec-valid`).toBe(true); |
| 290 | + expect(validateFlowTriggerReadiness(timeRelativeStack(descriptor)), label).toEqual([]); |
| 291 | + } |
| 292 | + }); |
| 293 | + |
| 294 | + it('reports a wrong object name and a wrong shape as two facts, not one twice', () => { |
| 295 | + // Criterion 3. `contract` is not in the stack AND the descriptor does not |
| 296 | + // parse. The two findings are distinguishable by rule id and by path, and |
| 297 | + // neither restates the other's fact: the unknown-object warning says nothing |
| 298 | + // about the shape, and the schema — which has no stack knowledge — cannot |
| 299 | + // say anything about the name. |
| 300 | + const findings = validateFlowTriggerReadiness( |
| 301 | + timeRelativeStack({ object: 'contract', field: 'end_date', withinDays: 60 }), |
| 302 | + ); |
| 303 | + expect(findings).toHaveLength(2); |
| 304 | + expect(findings.map((f) => f.rule)).toEqual([ |
| 305 | + FLOW_TRIGGER_UNKNOWN_OBJECT, |
| 306 | + FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, |
| 307 | + ]); |
| 308 | + expect(findings.map((f) => f.path)).toEqual([ |
| 309 | + 'flows[0].nodes[0].config.timeRelative.object', |
| 310 | + 'flows[0].nodes[0].config.timeRelative', |
| 311 | + ]); |
| 312 | + // The name warning talks only about the name… |
| 313 | + expect(findings[0].message).toContain("'contract'"); |
| 314 | + expect(findings[0].message).not.toContain('dateField'); |
| 315 | + // …and the shape warning only about the shape (it never echoes the name). |
| 316 | + expect(findings[1].message).toContain('dateField'); |
| 317 | + expect(findings[1].message).not.toContain("'contract'"); |
| 318 | + }); |
| 319 | + |
| 320 | + it('forwards the exactly-one-window rule (both modes, and neither)', () => { |
| 321 | + for (const descriptor of [ |
| 322 | + { object: 'task', dateField: 'due_at', withinDays: 3, offsetDays: [1] }, |
| 323 | + { object: 'task', dateField: 'due_at' }, |
| 324 | + ]) { |
| 325 | + const findings = validateFlowTriggerReadiness(timeRelativeStack(descriptor)); |
| 326 | + expect(findings.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID]); |
| 327 | + expect(findings[0].message).toContain('exactly one of `withinDays`'); |
| 328 | + } |
| 329 | + }); |
| 330 | + |
| 331 | + it("forwards the schema's wrong-layer guidance for a `schedule` written INSIDE the descriptor", () => { |
| 332 | + // The cadence knob is a SIBLING of `timeRelative` on the same config. The |
| 333 | + // schema carries that prescription; the value of forwarding is that the |
| 334 | + // author reads it from `os validate` instead of from a server log. |
| 335 | + const findings = validateFlowTriggerReadiness( |
| 336 | + timeRelativeStack({ |
| 337 | + object: 'task', |
| 338 | + dateField: 'due_at', |
| 339 | + withinDays: 3, |
| 340 | + schedule: { type: 'cron', expression: '0 8 * * *' }, |
| 341 | + }), |
| 342 | + ); |
| 343 | + expect(findings.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID]); |
| 344 | + expect(findings[0].message).toContain('`schedule` is a sibling of `timeRelative`'); |
| 345 | + }); |
| 346 | + |
| 347 | + it('flags an array descriptor — the engine routes it, so the trigger refuses it', () => { |
| 348 | + const findings = validateFlowTriggerReadiness(timeRelativeStack([{ object: 'task' }])); |
| 349 | + expect(findings.map((f) => f.rule)).toEqual([FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID]); |
| 350 | + expect(findings[0].message).toContain('expected object, received array'); |
| 351 | + }); |
| 352 | + |
| 353 | + it('says nothing about a non-object timeRelative — the engine does not route it here', () => { |
| 354 | + // `AutomationEngine`'s trigger resolution requires `typeof … === 'object'`, |
| 355 | + // so `timeRelative: 'daily'` never reaches the time-relative trigger and no |
| 356 | + // descriptor verdict applies to it. Whatever that flow's defect is, it is |
| 357 | + // not this rule's, and guessing here would make the rule speak for flows |
| 358 | + // the engine hands somewhere else. |
| 359 | + const findings = validateFlowTriggerReadiness(timeRelativeStack('daily')); |
| 360 | + expect(findings.some((f) => f.rule === FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID)).toBe(false); |
| 361 | + }); |
| 362 | + |
| 363 | + it('is inert on flows that declare no timeRelative at all', () => { |
| 364 | + const findings = validateFlowTriggerReadiness({ |
| 365 | + objects: [candidateObject], |
| 366 | + flows: [recordFlow({ status: 'active' })], |
| 367 | + }); |
| 368 | + expect(findings).toEqual([]); |
| 369 | + }); |
| 370 | + |
| 371 | + it('still flags the draft-status ambiguity alongside a bad descriptor', () => { |
| 372 | + const stack = timeRelativeStack(badDescriptor); |
| 373 | + delete (stack.flows[0] as Record<string, unknown>).status; |
| 374 | + const findings = validateFlowTriggerReadiness(stack); |
| 375 | + expect(findings.map((f) => f.rule)).toEqual([ |
| 376 | + FLOW_TIME_RELATIVE_DESCRIPTOR_INVALID, |
| 377 | + FLOW_DRAFT_STATUS_AMBIGUOUS, |
| 378 | + ]); |
| 379 | + }); |
177 | 380 | }); |
178 | 381 |
|
179 | 382 | it('passes the record-after-write (create-OR-update) token (#3427)', () => { |
|
0 commit comments