@manablox/workflows 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -0
- package/src/conditions.ts +88 -0
- package/src/cron.ts +181 -0
- package/src/engine.ts +499 -0
- package/src/index.ts +9 -0
- package/src/mail.ts +38 -0
- package/src/push.ts +61 -0
- package/src/service.ts +167 -0
- package/src/steps.ts +256 -0
- package/src/template.ts +65 -0
- package/src/validate.ts +267 -0
- package/test/conditions.test.ts +80 -0
- package/test/cron.test.ts +79 -0
- package/test/engine.test.ts +555 -0
- package/test/template.test.ts +41 -0
- package/test/validate.test.ts +129 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +9 -0
package/src/validate.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
type ErrorDetail,
|
|
4
|
+
isEmailAddress,
|
|
5
|
+
ManabloxError,
|
|
6
|
+
WORKFLOW_CONDITION_OPERATORS,
|
|
7
|
+
WORKFLOW_EVENTS,
|
|
8
|
+
WORKFLOW_STEP_TYPES,
|
|
9
|
+
type WorkflowStep,
|
|
10
|
+
type WorkflowTrigger,
|
|
11
|
+
} from '@manablox/core';
|
|
12
|
+
import { isValidCron, isValidTimezone } from './cron.js';
|
|
13
|
+
|
|
14
|
+
/** What the editor hands over: a workflow as typed, before it is trusted. */
|
|
15
|
+
export interface WorkflowInput {
|
|
16
|
+
name: string;
|
|
17
|
+
description?: string | null | undefined;
|
|
18
|
+
enabled?: boolean | undefined;
|
|
19
|
+
trigger: WorkflowTrigger;
|
|
20
|
+
steps: WorkflowStep[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ValidatedWorkflow {
|
|
24
|
+
name: string;
|
|
25
|
+
description: string | null;
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
trigger: WorkflowTrigger;
|
|
28
|
+
steps: WorkflowStep[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ValidationEnvironment {
|
|
32
|
+
/** Whether a content type id names a type of this space (or a code-defined one). */
|
|
33
|
+
typeExists: (typeId: string) => boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
37
|
+
const MAX_DELAY_MINUTES = 60 * 24 * 30;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Checks everything the shape alone cannot: an event that exists, a cron that parses, a
|
|
41
|
+
* step with somewhere to send to. Every problem is reported at once, each with the path
|
|
42
|
+
* of the field it concerns, so the editor can mark them all in one round.
|
|
43
|
+
*/
|
|
44
|
+
export function validateWorkflow(
|
|
45
|
+
input: WorkflowInput,
|
|
46
|
+
env: ValidationEnvironment,
|
|
47
|
+
): ValidatedWorkflow {
|
|
48
|
+
const problems: ErrorDetail[] = [];
|
|
49
|
+
const add = (
|
|
50
|
+
key: ErrorDetail['key'],
|
|
51
|
+
path: (string | number)[],
|
|
52
|
+
params?: Record<string, unknown>,
|
|
53
|
+
) => problems.push({ key, path, ...(params ? { params } : {}) });
|
|
54
|
+
|
|
55
|
+
const name = input.name.trim();
|
|
56
|
+
if (!name) add('workflow.name.required', ['name']);
|
|
57
|
+
|
|
58
|
+
const trigger = validateTrigger(input.trigger, env, add);
|
|
59
|
+
const steps = validateSteps(input.steps, ['steps'], add);
|
|
60
|
+
if (steps.length === 0) add('workflow.steps.required', ['steps']);
|
|
61
|
+
|
|
62
|
+
if (problems.length) throw ManabloxError.validation(problems, 'workflow.validation.failed');
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
description: input.description?.trim() || null,
|
|
67
|
+
enabled: input.enabled ?? false,
|
|
68
|
+
trigger,
|
|
69
|
+
steps,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Add = (
|
|
74
|
+
key: ErrorDetail['key'],
|
|
75
|
+
path: (string | number)[],
|
|
76
|
+
params?: Record<string, unknown>,
|
|
77
|
+
) => void;
|
|
78
|
+
|
|
79
|
+
function validateTrigger(
|
|
80
|
+
trigger: WorkflowTrigger,
|
|
81
|
+
env: ValidationEnvironment,
|
|
82
|
+
add: Add,
|
|
83
|
+
): WorkflowTrigger {
|
|
84
|
+
if (trigger.kind === 'schedule') {
|
|
85
|
+
if (!isValidCron(trigger.cron)) add('workflow.trigger.cronInvalid', ['trigger', 'cron']);
|
|
86
|
+
if (!isValidTimezone(trigger.timezone)) {
|
|
87
|
+
add('workflow.trigger.timezoneInvalid', ['trigger', 'timezone']);
|
|
88
|
+
}
|
|
89
|
+
for (const [index, typeId] of (trigger.selection?.typeIds ?? []).entries()) {
|
|
90
|
+
if (!env.typeExists(typeId)) {
|
|
91
|
+
add('workflow.trigger.typeNotFound', ['trigger', 'selection', 'typeIds', index], {
|
|
92
|
+
typeId,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
kind: 'schedule',
|
|
98
|
+
cron: trigger.cron.trim(),
|
|
99
|
+
timezone: trigger.timezone,
|
|
100
|
+
selection: trigger.selection
|
|
101
|
+
? {
|
|
102
|
+
typeIds: trigger.selection.typeIds,
|
|
103
|
+
status: trigger.selection.status,
|
|
104
|
+
changedWithinHours: trigger.selection.changedWithinHours ?? null,
|
|
105
|
+
locale: trigger.selection.locale || null,
|
|
106
|
+
}
|
|
107
|
+
: null,
|
|
108
|
+
perDocument: Boolean(trigger.perDocument && trigger.selection),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const events = [...new Set(trigger.events)];
|
|
113
|
+
if (events.length === 0) add('workflow.trigger.eventsRequired', ['trigger', 'events']);
|
|
114
|
+
for (const [index, event] of events.entries()) {
|
|
115
|
+
if (!(WORKFLOW_EVENTS as readonly string[]).includes(event)) {
|
|
116
|
+
add('workflow.trigger.eventUnknown', ['trigger', 'events', index], { event });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const [index, typeId] of trigger.typeIds.entries()) {
|
|
120
|
+
if (!env.typeExists(typeId)) {
|
|
121
|
+
add('workflow.trigger.typeNotFound', ['trigger', 'typeIds', index], { typeId });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { kind: 'event', events, typeIds: trigger.typeIds, locales: trigger.locales ?? [] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Checks every step of a chain, recursing into the two sides of a fork. */
|
|
128
|
+
function validateSteps(
|
|
129
|
+
steps: WorkflowStep[],
|
|
130
|
+
prefix: (string | number)[],
|
|
131
|
+
add: Add,
|
|
132
|
+
): WorkflowStep[] {
|
|
133
|
+
return steps.map((step, index) => validateStep(step, [...prefix, index], add));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateRules(
|
|
137
|
+
step: {
|
|
138
|
+
match: 'all' | 'any';
|
|
139
|
+
rules: WorkflowStep extends infer S ? (S extends { rules: infer R } ? R : never) : never;
|
|
140
|
+
},
|
|
141
|
+
at: (...rest: (string | number)[]) => (string | number)[],
|
|
142
|
+
add: Add,
|
|
143
|
+
) {
|
|
144
|
+
if (step.rules.length === 0) add('workflow.step.condition.rulesRequired', at('rules'));
|
|
145
|
+
for (const [i, rule] of step.rules.entries()) {
|
|
146
|
+
if (!rule.field.trim()) add('workflow.step.condition.fieldRequired', at('rules', i, 'field'));
|
|
147
|
+
if (!(WORKFLOW_CONDITION_OPERATORS as readonly string[]).includes(rule.operator)) {
|
|
148
|
+
add('workflow.step.typeUnknown', at('rules', i, 'operator'), { type: rule.operator });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
match: step.match === 'any' ? ('any' as const) : ('all' as const),
|
|
153
|
+
rules: step.rules.map((rule) => ({
|
|
154
|
+
field: rule.field.trim(),
|
|
155
|
+
operator: rule.operator,
|
|
156
|
+
value: rule.value ?? '',
|
|
157
|
+
})),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validateStep(step: WorkflowStep, path: (string | number)[], add: Add): WorkflowStep {
|
|
162
|
+
const at = (...rest: (string | number)[]) => [...path, ...rest];
|
|
163
|
+
const base = {
|
|
164
|
+
id: step.id || randomUUID(),
|
|
165
|
+
name: (step.name ?? '').trim(),
|
|
166
|
+
enabled: step.enabled ?? true,
|
|
167
|
+
continueOnError: step.continueOnError ?? false,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (!(WORKFLOW_STEP_TYPES as readonly string[]).includes(step.type)) {
|
|
171
|
+
add('workflow.step.typeUnknown', at('type'), { type: (step as { type: string }).type });
|
|
172
|
+
return step;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
switch (step.type) {
|
|
176
|
+
case 'email': {
|
|
177
|
+
const to = step.to.map((address) => address.trim()).filter(Boolean);
|
|
178
|
+
const toRoles = step.toRoles.map((role) => role.trim()).filter(Boolean);
|
|
179
|
+
if (to.length === 0 && toRoles.length === 0) {
|
|
180
|
+
add('workflow.step.email.recipientRequired', at('to'));
|
|
181
|
+
}
|
|
182
|
+
for (const [i, address] of to.entries()) {
|
|
183
|
+
// A template such as `{{ actor.email }}` is checked when it is rendered.
|
|
184
|
+
if (!address.includes('{{') && !isEmailAddress(address)) {
|
|
185
|
+
add('workflow.step.email.recipientInvalid', at('to', i), { address });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (!step.subject.trim()) add('workflow.step.email.subjectRequired', at('subject'));
|
|
189
|
+
return {
|
|
190
|
+
...base,
|
|
191
|
+
type: 'email',
|
|
192
|
+
to,
|
|
193
|
+
toRoles,
|
|
194
|
+
subject: step.subject.trim(),
|
|
195
|
+
body: step.body ?? '',
|
|
196
|
+
html: Boolean(step.html),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
case 'http': {
|
|
200
|
+
const url = step.url.trim();
|
|
201
|
+
if (!url.includes('{{') && !isHttpUrl(url)) add('workflow.step.http.urlInvalid', at('url'));
|
|
202
|
+
if (url.includes('{{') && !/^https?:\/\//i.test(url)) {
|
|
203
|
+
add('workflow.step.http.urlInvalid', at('url'));
|
|
204
|
+
}
|
|
205
|
+
const headers = step.headers
|
|
206
|
+
.map((header) => ({ name: header.name.trim(), value: header.value }))
|
|
207
|
+
.filter((header) => header.name || header.value.trim());
|
|
208
|
+
for (const [i, header] of headers.entries()) {
|
|
209
|
+
if (!HEADER_NAME.test(header.name)) {
|
|
210
|
+
add('workflow.step.http.headerNameInvalid', at('headers', i, 'name'), {
|
|
211
|
+
name: header.name,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
type: 'http',
|
|
218
|
+
method: step.method,
|
|
219
|
+
url,
|
|
220
|
+
headers,
|
|
221
|
+
body: { mode: step.body?.mode ?? 'event', template: step.body?.template ?? '' },
|
|
222
|
+
secret: step.secret?.trim() || null,
|
|
223
|
+
timeoutMs: clamp(step.timeoutMs ?? 10_000, 1_000, 120_000),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
case 'push': {
|
|
227
|
+
if (!step.title.trim()) add('workflow.step.push.titleRequired', at('title'));
|
|
228
|
+
return {
|
|
229
|
+
...base,
|
|
230
|
+
type: 'push',
|
|
231
|
+
roles: step.roles.map((role) => role.trim()).filter(Boolean),
|
|
232
|
+
userIds: step.userIds.filter(Boolean),
|
|
233
|
+
title: step.title.trim(),
|
|
234
|
+
body: step.body ?? '',
|
|
235
|
+
url: step.url?.trim() ?? '',
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
case 'condition':
|
|
239
|
+
return { ...base, type: 'condition', ...validateRules(step, at, add) };
|
|
240
|
+
case 'branch':
|
|
241
|
+
return {
|
|
242
|
+
...base,
|
|
243
|
+
type: 'branch',
|
|
244
|
+
...validateRules(step, at, add),
|
|
245
|
+
then: validateSteps(step.then ?? [], at('then'), add),
|
|
246
|
+
else: validateSteps(step.else ?? [], at('else'), add),
|
|
247
|
+
};
|
|
248
|
+
case 'delay': {
|
|
249
|
+
const minutes = Number(step.minutes);
|
|
250
|
+
if (!Number.isFinite(minutes) || minutes < 1 || minutes > MAX_DELAY_MINUTES) {
|
|
251
|
+
add('workflow.step.delay.minutesInvalid', at('minutes'), { max: MAX_DELAY_MINUTES });
|
|
252
|
+
}
|
|
253
|
+
return { ...base, type: 'delay', minutes: Math.round(minutes) };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isHttpUrl(value: string): boolean {
|
|
259
|
+
try {
|
|
260
|
+
const url = new URL(value);
|
|
261
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { WorkflowConditionStep, WorkflowRunContext } from '@manablox/core';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { evaluateCondition, evaluateRule } from '../src/conditions.js';
|
|
4
|
+
|
|
5
|
+
const base: WorkflowRunContext = {
|
|
6
|
+
event: 'content.updated',
|
|
7
|
+
workflow: { id: 'w', name: 'W' },
|
|
8
|
+
space: { id: 's', name: 'S', machineName: 's', url: '' },
|
|
9
|
+
content: { status: 'published', title: 'Launch', fields: { tags: ['news'], score: 7, note: '' } },
|
|
10
|
+
previous: { status: 'draft', title: 'Launch', fields: { tags: ['news'], score: 5, note: '' } },
|
|
11
|
+
documents: [],
|
|
12
|
+
actor: { id: 'u', name: 'Ann', email: 'ann@example.com' },
|
|
13
|
+
url: null,
|
|
14
|
+
at: '2026-09-06T00:00:00.000Z',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const rule = (
|
|
18
|
+
field: string,
|
|
19
|
+
operator: WorkflowConditionStep['rules'][number]['operator'],
|
|
20
|
+
value = '',
|
|
21
|
+
) => evaluateRule({ field, operator, value }, base);
|
|
22
|
+
|
|
23
|
+
describe('evaluateRule', () => {
|
|
24
|
+
it('compares loosely across types', () => {
|
|
25
|
+
expect(rule('content.status', 'equals', 'published')).toBe(true);
|
|
26
|
+
expect(rule('content.fields.score', 'equals', '7')).toBe(true);
|
|
27
|
+
expect(rule('content.status', 'notEquals', 'draft')).toBe(true);
|
|
28
|
+
expect(rule('content.fields.tags', 'contains', 'news')).toBe(true);
|
|
29
|
+
expect(rule('content.title', 'contains', 'LAUNCH')).toBe(true);
|
|
30
|
+
expect(rule('content.title', 'startsWith', 'la')).toBe(true);
|
|
31
|
+
expect(rule('content.fields.note', 'isEmpty')).toBe(true);
|
|
32
|
+
expect(rule('content.fields.tags', 'isNotEmpty')).toBe(true);
|
|
33
|
+
expect(rule('content.fields.score', 'greaterThan', '6')).toBe(true);
|
|
34
|
+
expect(rule('content.fields.score', 'lessThan', '6')).toBe(false);
|
|
35
|
+
expect(rule('content.missing', 'isEmpty')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reads the previous row for "changed"', () => {
|
|
39
|
+
expect(rule('content.status', 'changed')).toBe(true);
|
|
40
|
+
expect(rule('content.title', 'changed')).toBe(false);
|
|
41
|
+
expect(rule('content.fields.score', 'changed')).toBe(true);
|
|
42
|
+
expect(rule('content.fields.tags', 'changed')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('treats everything as changed without a previous row', () => {
|
|
46
|
+
expect(
|
|
47
|
+
evaluateRule(
|
|
48
|
+
{ field: 'content.title', operator: 'changed', value: '' },
|
|
49
|
+
{ ...base, previous: null },
|
|
50
|
+
),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders the expected side as a template', () => {
|
|
55
|
+
expect(rule('actor.email', 'equals', '{{ actor.email }}')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('evaluateCondition', () => {
|
|
60
|
+
const step = (
|
|
61
|
+
match: 'all' | 'any',
|
|
62
|
+
...rules: WorkflowConditionStep['rules']
|
|
63
|
+
): WorkflowConditionStep => ({
|
|
64
|
+
id: 'c',
|
|
65
|
+
name: '',
|
|
66
|
+
enabled: true,
|
|
67
|
+
continueOnError: false,
|
|
68
|
+
type: 'condition',
|
|
69
|
+
match,
|
|
70
|
+
rules,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('combines rules', () => {
|
|
74
|
+
const yes = { field: 'content.status', operator: 'equals', value: 'published' } as const;
|
|
75
|
+
const no = { field: 'content.status', operator: 'equals', value: 'draft' } as const;
|
|
76
|
+
expect(evaluateCondition(step('all', yes, no), base)).toBe(false);
|
|
77
|
+
expect(evaluateCondition(step('any', yes, no), base)).toBe(true);
|
|
78
|
+
expect(evaluateCondition(step('all'), base)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { cronMatches, floorToMinute, isValidTimezone, parseCron, wallClock } from '../src/cron.js';
|
|
3
|
+
|
|
4
|
+
const at = (iso: string) => new Date(iso);
|
|
5
|
+
|
|
6
|
+
describe('parseCron', () => {
|
|
7
|
+
it('accepts the common forms', () => {
|
|
8
|
+
expect(parseCron('* * * * *')).not.toBeNull();
|
|
9
|
+
expect(parseCron('0 8 * * 1-5')).not.toBeNull();
|
|
10
|
+
expect(parseCron('*/15 * * * *')).not.toBeNull();
|
|
11
|
+
expect(parseCron('0 0 1 jan,jul *')).not.toBeNull();
|
|
12
|
+
expect(parseCron('30 6 * * sun')).not.toBeNull();
|
|
13
|
+
expect(parseCron('5/10 * * * *')?.minute).toEqual(new Set([5, 15, 25, 35, 45, 55]));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('rejects what it cannot read', () => {
|
|
17
|
+
expect(parseCron('')).toBeNull();
|
|
18
|
+
expect(parseCron('* * * *')).toBeNull();
|
|
19
|
+
expect(parseCron('60 * * * *')).toBeNull();
|
|
20
|
+
expect(parseCron('* 24 * * *')).toBeNull();
|
|
21
|
+
expect(parseCron('*/0 * * * *')).toBeNull();
|
|
22
|
+
expect(parseCron('a b c d e')).toBeNull();
|
|
23
|
+
expect(parseCron('5-3 * * * *')).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('treats 7 as Sunday', () => {
|
|
27
|
+
expect(parseCron('0 0 * * 7')?.dayOfWeek).toEqual(new Set([0]));
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('cronMatches', () => {
|
|
32
|
+
it('matches a plain minute', () => {
|
|
33
|
+
const spec = parseCron('30 14 * * *')!;
|
|
34
|
+
expect(cronMatches(spec, at('2026-09-06T14:30:00Z'), 'UTC')).toBe(true);
|
|
35
|
+
expect(cronMatches(spec, at('2026-09-06T14:31:00Z'), 'UTC')).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('reads the wall-clock in the workflow timezone', () => {
|
|
39
|
+
const spec = parseCron('0 8 * * *')!;
|
|
40
|
+
// 08:00 in Vienna during summer time is 06:00 UTC.
|
|
41
|
+
expect(cronMatches(spec, at('2026-07-01T06:00:00Z'), 'Europe/Vienna')).toBe(true);
|
|
42
|
+
expect(cronMatches(spec, at('2026-07-01T08:00:00Z'), 'Europe/Vienna')).toBe(false);
|
|
43
|
+
expect(cronMatches(spec, at('2026-07-01T08:00:00Z'), 'UTC')).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('crosses the date line correctly for weekdays', () => {
|
|
47
|
+
// Saturday 23:30 in Los Angeles is Sunday 06:30 UTC.
|
|
48
|
+
const clock = wallClock(at('2026-09-06T06:30:00Z'), 'America/Los_Angeles');
|
|
49
|
+
expect(clock).toMatchObject({ hour: 23, minute: 30, dayOfWeek: 6, dayOfMonth: 5 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('combines the day fields like Vixie cron', () => {
|
|
53
|
+
// Either the 1st of the month or a Monday.
|
|
54
|
+
const spec = parseCron('0 0 1 * 1')!;
|
|
55
|
+
expect(cronMatches(spec, at('2026-09-01T00:00:00Z'), 'UTC')).toBe(true); // Tuesday the 1st
|
|
56
|
+
expect(cronMatches(spec, at('2026-09-07T00:00:00Z'), 'UTC')).toBe(true); // Monday the 7th
|
|
57
|
+
expect(cronMatches(spec, at('2026-09-08T00:00:00Z'), 'UTC')).toBe(false);
|
|
58
|
+
// With `*` in day-of-month, only the weekday counts.
|
|
59
|
+
expect(cronMatches(parseCron('0 0 * * 1')!, at('2026-09-01T00:00:00Z'), 'UTC')).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles midnight as hour 0', () => {
|
|
63
|
+
expect(cronMatches(parseCron('0 0 * * *')!, at('2026-09-06T00:00:00Z'), 'UTC')).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('helpers', () => {
|
|
68
|
+
it('validates timezones', () => {
|
|
69
|
+
expect(isValidTimezone('UTC')).toBe(true);
|
|
70
|
+
expect(isValidTimezone('Europe/Vienna')).toBe(true);
|
|
71
|
+
expect(isValidTimezone('Mars/Olympus')).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('floors to the minute', () => {
|
|
75
|
+
expect(floorToMinute(at('2026-09-06T14:30:59.999Z')).toISOString()).toBe(
|
|
76
|
+
'2026-09-06T14:30:00.000Z',
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|