Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

cron.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

cron.test.tsBlame244 lines · 1 contributor
665c8bfClaude1/**
2 * Tests for src/lib/cron.ts.
3 *
4 * Pure module — no DB, no clock side effects. We exhaustively cover:
5 * - Field parser edge cases (literals, ranges, steps, lists, wildcards)
6 * - Whole-expression parser (5-field shape, errors)
7 * - cronMatches per-minute logic
8 * - cronFiredBetween interval semantics
9 * - POSIX OR semantics for dom & dow
10 */
11
12import { describe, it, expect } from "bun:test";
13import {
14 parseCron,
15 cronMatches,
16 cronFiredBetween,
17 __test,
18} from "../lib/cron";
19
20const at = (iso: string) => new Date(iso);
21
22describe("parseField", () => {
23 const f = __test.parseField;
24
25 it("expands * to the full range", () => {
26 expect(f("*", 0, 4)).toEqual([0, 1, 2, 3, 4]);
27 });
28
29 it("parses literals", () => {
30 expect(f("3", 0, 59)).toEqual([3]);
31 });
32
33 it("parses ranges", () => {
34 expect(f("2-5", 0, 23)).toEqual([2, 3, 4, 5]);
35 });
36
37 it("parses steps with wildcard base", () => {
38 expect(f("*/15", 0, 59)).toEqual([0, 15, 30, 45]);
39 });
40
41 it("parses steps with range base", () => {
42 expect(f("0-10/2", 0, 59)).toEqual([0, 2, 4, 6, 8, 10]);
43 });
44
45 it("parses comma-separated lists with mixed forms", () => {
46 expect(f("0,15,30-32,*/30", 0, 59)).toEqual([0, 15, 30, 31, 32]);
47 });
48
49 it("rejects out-of-range values", () => {
50 expect(f("60", 0, 59)).toBeNull();
51 expect(f("-1", 0, 59)).toBeNull();
52 });
53
54 it("rejects bogus syntax", () => {
55 expect(f("a", 0, 59)).toBeNull();
56 expect(f("1-", 0, 59)).toBeNull();
57 expect(f("/5", 0, 59)).toBeNull();
58 expect(f("1/0", 0, 59)).toBeNull();
59 });
60
61 it("rejects literal+step combinations (1/5 makes no sense)", () => {
62 expect(f("5/2", 0, 59)).toBeNull();
63 });
64
65 it("rejects descending ranges", () => {
66 expect(f("5-3", 0, 59)).toBeNull();
67 });
68});
69
70describe("parseCron — error paths", () => {
71 it("rejects empty input", () => {
72 expect(parseCron("")).toEqual({ ok: false, error: "empty cron expression" });
73 });
74
75 it("rejects @-aliases", () => {
76 const r = parseCron("@hourly");
77 expect(r.ok).toBe(false);
78 if (!r.ok) expect(r.error).toContain("not supported");
79 });
80
81 it("rejects unsupported chars (L W # ?)", () => {
82 const r = parseCron("0 0 L * *");
83 expect(r.ok).toBe(false);
84 if (!r.ok) expect(r.error).toContain("unsupported characters");
85 });
86
87 it("rejects wrong number of fields", () => {
88 expect(parseCron("0 0 *").ok).toBe(false);
89 expect(parseCron("0 0 * * * *").ok).toBe(false);
90 });
91
92 it("collapses whitespace before counting fields", () => {
93 expect(parseCron("0 * * * *").ok).toBe(true);
94 });
95});
96
97describe("parseCron — happy path", () => {
98 it("every minute → all minutes/hours/dows/etc full", () => {
99 const r = parseCron("* * * * *");
100 expect(r.ok).toBe(true);
101 if (!r.ok) return;
102 expect(r.cron.minute.length).toBe(60);
103 expect(r.cron.hour.length).toBe(24);
104 expect(r.cron.dom.length).toBe(31);
105 expect(r.cron.month.length).toBe(12);
106 expect(r.cron.dow.length).toBe(7);
107 });
108
109 it("normalises dow=7 to dow=0", () => {
110 const r = parseCron("0 0 * * 7");
111 expect(r.ok).toBe(true);
112 if (!r.ok) return;
113 expect(r.cron.dow).toEqual([0]);
114 });
115
116 it("dedupes (e.g. 0,0,0)", () => {
117 const r = parseCron("0,0,0 * * * *");
118 expect(r.ok).toBe(true);
119 if (!r.ok) return;
120 expect(r.cron.minute).toEqual([0]);
121 });
122});
123
124describe("cronMatches", () => {
125 it("matches every-minute cron at any timestamp", () => {
126 const r = parseCron("* * * * *");
127 if (!r.ok) throw new Error("setup");
128 expect(cronMatches(r.cron, at("2026-04-30T12:34:56Z"))).toBe(true);
129 });
130
131 it("matches a specific minute only at that minute", () => {
132 const r = parseCron("0 * * * *");
133 if (!r.ok) throw new Error("setup");
134 expect(cronMatches(r.cron, at("2026-04-30T12:00:00Z"))).toBe(true);
135 expect(cronMatches(r.cron, at("2026-04-30T12:01:00Z"))).toBe(false);
136 });
137
138 it("matches a specific hour:minute (daily)", () => {
139 const r = parseCron("30 9 * * *");
140 if (!r.ok) throw new Error("setup");
141 expect(cronMatches(r.cron, at("2026-04-30T09:30:00Z"))).toBe(true);
142 expect(cronMatches(r.cron, at("2026-04-30T09:00:00Z"))).toBe(false);
143 expect(cronMatches(r.cron, at("2026-04-30T10:30:00Z"))).toBe(false);
144 });
145
146 it("matches by day-of-week (Mondays at 09:00)", () => {
147 const r = parseCron("0 9 * * 1");
148 if (!r.ok) throw new Error("setup");
149 // 2026-04-27 was a Monday. (Verified via UTC.)
150 expect(cronMatches(r.cron, at("2026-04-27T09:00:00Z"))).toBe(true);
151 // 2026-04-28 Tuesday
152 expect(cronMatches(r.cron, at("2026-04-28T09:00:00Z"))).toBe(false);
153 });
154
155 it("uses POSIX OR for dom + dow when both are restricted", () => {
156 // "Run on the 1st of the month OR every Friday at 12:00"
157 const r = parseCron("0 12 1 * 5");
158 if (!r.ok) throw new Error("setup");
159 // 2026-04-01 was a Wednesday (1st of April) → dom matches → fire.
160 expect(cronMatches(r.cron, at("2026-04-01T12:00:00Z"))).toBe(true);
161 // 2026-04-03 was a Friday → dow matches → fire.
162 expect(cronMatches(r.cron, at("2026-04-03T12:00:00Z"))).toBe(true);
163 // 2026-04-04 Saturday, not 1st → no fire.
164 expect(cronMatches(r.cron, at("2026-04-04T12:00:00Z"))).toBe(false);
165 });
166
167 it("matches every 15 minutes (*/15)", () => {
168 const r = parseCron("*/15 * * * *");
169 if (!r.ok) throw new Error("setup");
170 for (const min of [0, 15, 30, 45]) {
171 expect(
172 cronMatches(r.cron, at(`2026-04-30T08:${String(min).padStart(2, "0")}:00Z`))
173 ).toBe(true);
174 }
175 for (const min of [1, 16, 31, 46]) {
176 expect(
177 cronMatches(r.cron, at(`2026-04-30T08:${String(min).padStart(2, "0")}:00Z`))
178 ).toBe(false);
179 }
180 });
181});
182
183describe("cronFiredBetween", () => {
184 it("returns true when at least one minute in (since, until] matches", () => {
185 const r = parseCron("0 * * * *");
186 if (!r.ok) throw new Error("setup");
187 // since 12:30, until 13:05 — 13:00 lies in (since, until], should fire.
188 const fired = cronFiredBetween(
189 r.cron,
190 at("2026-04-30T12:30:00Z"),
191 at("2026-04-30T13:05:00Z")
192 );
193 expect(fired).toBe(true);
194 });
195
196 it("returns false when no matching minute in the interval", () => {
197 const r = parseCron("0 * * * *");
198 if (!r.ok) throw new Error("setup");
199 const fired = cronFiredBetween(
200 r.cron,
201 at("2026-04-30T12:01:00Z"),
202 at("2026-04-30T12:30:00Z")
203 );
204 expect(fired).toBe(false);
205 });
206
207 it("excludes the `since` boundary (half-open)", () => {
208 const r = parseCron("0 * * * *");
209 if (!r.ok) throw new Error("setup");
210 // since exactly at 12:00 — must not fire that same minute, only the
211 // next 12:00 would (which is an hour later). Until = 12:30 → false.
212 expect(
213 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T12:30:00Z"))
214 ).toBe(false);
215 });
216
217 it("includes the `until` boundary (half-open at the right)", () => {
218 const r = parseCron("30 * * * *");
219 if (!r.ok) throw new Error("setup");
220 expect(
221 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T12:30:00Z"))
222 ).toBe(true);
223 });
224
225 it("returns false on a zero/negative interval", () => {
226 const r = parseCron("* * * * *");
227 if (!r.ok) throw new Error("setup");
228 expect(
229 cronFiredBetween(r.cron, at("2026-04-30T12:00:00Z"), at("2026-04-30T11:00:00Z"))
230 ).toBe(false);
231 });
232
233 it("caps the lookback at 1 day so a misconfigured `since` cannot blow up", () => {
234 const r = parseCron("* * * * *");
235 if (!r.ok) throw new Error("setup");
236 // since in 2020, until now — should still return true (every-minute
237 // cron always fires) without iterating millions of minutes.
238 const start = Date.now();
239 expect(
240 cronFiredBetween(r.cron, at("2020-01-01T00:00:00Z"), new Date())
241 ).toBe(true);
242 expect(Date.now() - start).toBeLessThan(1000);
243 });
244});