|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { describe, expect, it } from 'vitest'; |
| 4 | +import { FlowSchema } from './flow.zod'; |
| 5 | +import { flowStartConfigRefusals, flowStartScheduleReadsNothing } from './flow-start-config-refusals'; |
| 6 | + |
| 7 | +const flowWith = (config: Record<string, unknown>) => ({ |
| 8 | + name: 'start_config_probe', |
| 9 | + label: 'Start Config Probe', |
| 10 | + type: 'schedule', |
| 11 | + runAs: 'system', |
| 12 | + nodes: [ |
| 13 | + { id: 'start', type: 'start', label: 'Start', config }, |
| 14 | + { id: 'end', type: 'end', label: 'End' }, |
| 15 | + ], |
| 16 | + edges: [{ id: 'e1', source: 'start', target: 'end' }], |
| 17 | +}); |
| 18 | + |
| 19 | +/** The parse's issues anchored on the start node's config. */ |
| 20 | +const startIssues = (config: Record<string, unknown>) => { |
| 21 | + const parsed = FlowSchema.safeParse(flowWith(config)); |
| 22 | + if (parsed.success) return []; |
| 23 | + return parsed.error.issues |
| 24 | + .filter((i) => i.path[0] === 'nodes' && i.path[1] === 0 && i.path[2] === 'config') |
| 25 | + .map((i) => ({ path: i.path.join('.'), message: i.message })); |
| 26 | +}; |
| 27 | + |
| 28 | +const TIME_RELATIVE = { |
| 29 | + object: 'contract', |
| 30 | + dateField: 'end_date', |
| 31 | + offsetDays: -30, |
| 32 | +}; |
| 33 | + |
| 34 | +describe('flow start config — `filter` beside `timeRelative` (#31224 B1)', () => { |
| 35 | + it('refuses it at the parse, naming where the filter belongs', () => { |
| 36 | + const issues = startIssues({ triggerType: 'time_relative', timeRelative: TIME_RELATIVE, filter: { status: 'active' } }); |
| 37 | + expect(issues).toHaveLength(1); |
| 38 | + expect(issues[0]!.path).toBe('nodes.0.config.filter'); |
| 39 | + expect(issues[0]!.message).toMatch(/nothing reads it/); |
| 40 | + expect(issues[0]!.message).toMatch(/launches for every record in the date window/); |
| 41 | + expect(issues[0]!.message).toMatch(/Move it into `config\.timeRelative\.filter`/); |
| 42 | + }); |
| 43 | + |
| 44 | + it('accepts the filter inside `timeRelative`, and a `filter` on a start node with no `timeRelative`', () => { |
| 45 | + expect(flowStartConfigRefusals(flowWith({ timeRelative: { ...TIME_RELATIVE, filter: { status: 'active' } } }))).toEqual([]); |
| 46 | + expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: '0 8 * * *', filter: { a: 1 } }))).toEqual([]); |
| 47 | + }); |
| 48 | + |
| 49 | + it('judges no `filter` beside `timeRelative: null`: the trigger-kind resolver does not route that start node as a sweep', () => { |
| 50 | + expect(flowStartConfigRefusals(flowWith({ timeRelative: null, filter: { status: 'active' } }))).toEqual([]); |
| 51 | + expect(startIssues({ timeRelative: null, filter: { status: 'active' } })).toEqual([]); |
| 52 | + }); |
| 53 | +}); |
| 54 | + |
| 55 | +describe('flow start config — `schedule` (#31224 B1, ruling option (a))', () => { |
| 56 | + it('accepts the bare cron string, the three typed forms, and the untyped aliases the reader takes', () => { |
| 57 | + for (const schedule of [ |
| 58 | + '0 8 * * *', |
| 59 | + { type: 'cron', expression: '0 8 * * *' }, |
| 60 | + { type: 'cron', expression: '0 8 * * *', timezone: 'America/New_York' }, |
| 61 | + { type: 'interval', intervalMs: 60000 }, |
| 62 | + { type: 'once', at: '2027-01-01T08:00:00Z' }, |
| 63 | + { cron: '0 8 * * *' }, |
| 64 | + { expression: '0 8 * * *' }, |
| 65 | + { every: 60000 }, |
| 66 | + { intervalMs: 60000 }, |
| 67 | + { at: '2027-01-01T08:00:00Z' }, |
| 68 | + ]) { |
| 69 | + expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule })), JSON.stringify(schedule)).toEqual([]); |
| 70 | + } |
| 71 | + }); |
| 72 | + |
| 73 | + it('refuses a typed cron whose timezone is not an IANA zone, at the parse', () => { |
| 74 | + const issues = startIssues({ triggerType: 'schedule', schedule: { type: 'cron', expression: '0 8 * * *', timezone: 'UTC+8' } }); |
| 75 | + expect(issues).toHaveLength(1); |
| 76 | + expect(issues[0]!.path).toBe('nodes.0.config.schedule'); |
| 77 | + expect(issues[0]!.message).toMatch(/is not a schedule at `schedule\.timezone`/); |
| 78 | + expect(issues[0]!.message).toMatch(/a schedule flow stays unbound/); |
| 79 | + }); |
| 80 | + |
| 81 | + it('refuses a typed form the published ScheduleSchema refuses (unknown type, non-positive interval, no expression)', () => { |
| 82 | + for (const schedule of [ |
| 83 | + { type: 'hourly' }, |
| 84 | + { type: 'interval', intervalMs: 0 }, |
| 85 | + { type: 'cron' }, |
| 86 | + { type: 'once', at: 'tomorrow' }, |
| 87 | + ]) { |
| 88 | + const found = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule })); |
| 89 | + expect(found.map((f) => f.path.join('.')), JSON.stringify(schedule)).toEqual(['nodes.0.config.schedule']); |
| 90 | + } |
| 91 | + }); |
| 92 | + |
| 93 | + it('refuses an untyped value the reader reads nothing from, and an empty string', () => { |
| 94 | + for (const schedule of [{ cadence: 'daily' }, {}, [], 42, true, ' ', { every: -5 }]) { |
| 95 | + const found = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule })); |
| 96 | + expect(found, JSON.stringify(schedule)).toHaveLength(1); |
| 97 | + expect(found[0]!.message).toMatch(/schedule flow stays unbound and a time-relative sweep falls back to daily 08:00 UTC/); |
| 98 | + } |
| 99 | + }); |
| 100 | + |
| 101 | + it('refuses the typed aliases the published ScheduleSchema does not declare, naming the declared spelling', () => { |
| 102 | + const cron = startIssues({ triggerType: 'schedule', schedule: { type: 'cron', cron: '0 8 * * *' } }); |
| 103 | + expect(cron).toHaveLength(1); |
| 104 | + expect(cron[0]!.path).toBe('nodes.0.config.schedule'); |
| 105 | + expect(cron[0]!.message).toMatch(/is not a schedule at `schedule\.expression`/); |
| 106 | + expect(cron[0]!.message).toMatch(/names its cron in `expression`, not `cron`/); |
| 107 | + |
| 108 | + const every = startIssues({ triggerType: 'schedule', schedule: { type: 'interval', every: 60000 } }); |
| 109 | + expect(every).toHaveLength(1); |
| 110 | + expect(every[0]!.path).toBe('nodes.0.config.schedule'); |
| 111 | + expect(every[0]!.message).toMatch(/is not a schedule at `schedule\.intervalMs`/); |
| 112 | + expect(every[0]!.message).toMatch(/names its period in `intervalMs`, not `every`/); |
| 113 | + |
| 114 | + // Without the alias key, no alias hint. |
| 115 | + const bare = flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: { type: 'cron' } })); |
| 116 | + expect(bare[0]!.message).not.toMatch(/not `cron`/); |
| 117 | + }); |
| 118 | + |
| 119 | + it('refuses a typed cron whose expression is an envelope: it passes the schema, and the reader reads nothing', () => { |
| 120 | + const found = flowStartConfigRefusals(flowWith({ |
| 121 | + triggerType: 'schedule', |
| 122 | + schedule: { type: 'cron', expression: { dialect: 'cron', source: '0 8 * * *' } }, |
| 123 | + })); |
| 124 | + expect(found).toHaveLength(1); |
| 125 | + expect(found[0]!.message).toMatch(/names no schedule the readers can use/); |
| 126 | + }); |
| 127 | + |
| 128 | + it('judges no absent or null schedule', () => { |
| 129 | + expect(flowStartConfigRefusals(flowWith({ triggerType: 'schedule', schedule: null }))).toEqual([]); |
| 130 | + expect(flowStartConfigRefusals(flowWith({ triggerType: 'manual' }))).toEqual([]); |
| 131 | + }); |
| 132 | + |
| 133 | + it('reads only top-level start nodes', () => { |
| 134 | + const flow = flowWith({ triggerType: 'manual' }) as any; |
| 135 | + flow.nodes.splice(1, 0, { id: 'mid', type: 'assignment', label: 'Mid', config: { schedule: {} } }); |
| 136 | + expect(flowStartConfigRefusals(flow)).toEqual([]); |
| 137 | + }); |
| 138 | +}); |
| 139 | + |
| 140 | +describe('flowStartScheduleReadsNothing — the reader\'s precedence', () => { |
| 141 | + it('the first candidate present decides, as `normalizeSchedule` does', () => { |
| 142 | + // `intervalMs` is present and unusable, so the reader never reaches `every`. |
| 143 | + expect(flowStartScheduleReadsNothing({ intervalMs: -1, every: 60000 })).toBe(true); |
| 144 | + expect(flowStartScheduleReadsNothing({ expression: '', cron: '0 8 * * *' })).toBe(false); |
| 145 | + expect(flowStartScheduleReadsNothing(undefined)).toBe(false); |
| 146 | + }); |
| 147 | +}); |
0 commit comments