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.
| 665c8bf | 1 | /** |
| 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 | ||
| 12 | import { describe, it, expect } from "bun:test"; | |
| 13 | import { | |
| 14 | parseCron, | |
| 15 | cronMatches, | |
| 16 | cronFiredBetween, | |
| 17 | __test, | |
| 18 | } from "../lib/cron"; | |
| 19 | ||
| 20 | const at = (iso: string) => new Date(iso); | |
| 21 | ||
| 22 | describe("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 | ||
| 70 | describe("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 | ||
| 97 | describe("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 | ||
| 124 | describe("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 | ||
| 183 | describe("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 | }); |