| 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { CronError, cronMatches, nextCronMatch, parseCron, validateCron } from "../src/index.js"; |
| 3 | |
| 4 | const d = (iso: string) => new Date(iso); |
| 5 | |
| 6 | describe("parseCron", () => { |
| 7 | it("parses wildcards, lists, ranges and steps", () => { |
| 8 | const c = parseCron("*/15 0-6/2 1,15 * *"); |
| 9 | expect([...c.minutes]).toEqual([0, 15, 30, 45]); |
| 10 | expect([...c.hours]).toEqual([0, 2, 4, 6]); |
| 11 | expect([...c.daysOfMonth]).toEqual([1, 15]); |
| 12 | expect(c.months.size).toBe(12); |
| 13 | expect(c.daysOfWeek.size).toBe(7); |
| 14 | }); |
| 15 | |
| 16 | it("supports month and day names (case-insensitive) and 7 as Sunday", () => { |
| 17 | const c = parseCron("0 0 * jan-mar,DEC MON-FRI"); |
| 18 | expect([...c.months]).toEqual([1, 2, 3, 12]); |
| 19 | expect([...c.daysOfWeek]).toEqual([1, 2, 3, 4, 5]); |
| 20 | expect([...parseCron("0 0 * * 7").daysOfWeek]).toEqual([0]); |
| 21 | expect([...parseCron("0 0 * * 5-7").daysOfWeek].sort()).toEqual([0, 5, 6]); |
| 22 | }); |
| 23 | |
| 24 | it("supports a start/step without a range", () => { |
| 25 | expect([...parseCron("5/20 * * * *").minutes]).toEqual([5, 25, 45]); |
| 26 | }); |
| 27 | |
| 28 | const invalid = ["", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *", "* * * * 8", |
| 29 | "*/0 * * * *", "5-1 * * * *", "a * * * *", "1,,2 * * * *", "* * * foo *"]; |
| 30 | for (const cron of invalid) { |
| 31 | it(`rejects ${JSON.stringify(cron)}`, () => { |
| 32 | expect(() => parseCron(cron)).toThrow(CronError); |
| 33 | expect(validateCron(cron)).toBeTypeOf("string"); |
| 34 | }); |
| 35 | } |
| 36 | }); |
| 37 | |
| 38 | describe("cronMatches", () => { |
| 39 | it("matches in UTC", () => { |
| 40 | expect(cronMatches("30 5 * * *", d("2026-10-12T05:30:00Z"))).toBe(true); |
| 41 | expect(cronMatches("30 5 * * *", d("2026-10-12T05:31:00Z"))).toBe(false); |
| 42 | expect(cronMatches("30 5 * * *", d("2026-10-12T05:30:00+02:00"))).toBe(false); |
| 43 | }); |
| 44 | |
| 45 | it("matches weekdays", () => { |
| 46 | // 2026-10-12 is a Monday. |
| 47 | expect(cronMatches("0 9 * * MON", d("2026-10-12T09:00:00Z"))).toBe(true); |
| 48 | expect(cronMatches("0 9 * * 2", d("2026-10-12T09:00:00Z"))).toBe(false); |
| 49 | expect(cronMatches("30 5 * * 1,3", d("2026-10-14T05:30:00Z"))).toBe(true); |
| 50 | }); |
| 51 | |
| 52 | it("day-of-month OR day-of-week when both are restricted", () => { |
| 53 | // 1st of month or Friday |
| 54 | expect(cronMatches("0 0 1 * 5", d("2026-10-01T00:00:00Z"))).toBe(true); // Thursday, 1st |
| 55 | expect(cronMatches("0 0 1 * 5", d("2026-10-16T00:00:00Z"))).toBe(true); // Friday |
| 56 | expect(cronMatches("0 0 1 * 5", d("2026-10-15T00:00:00Z"))).toBe(false); |
| 57 | // With dow wildcard, only dom applies. |
| 58 | expect(cronMatches("0 0 1 * *", d("2026-10-16T00:00:00Z"))).toBe(false); |
| 59 | expect(cronMatches("0 0 * * 5", d("2026-10-01T00:00:00Z"))).toBe(false); |
| 60 | }); |
| 61 | |
| 62 | it("matches months", () => { |
| 63 | expect(cronMatches("0 0 1 JAN *", d("2027-01-01T00:00:00Z"))).toBe(true); |
| 64 | expect(cronMatches("0 0 1 JAN *", d("2026-11-01T00:00:00Z"))).toBe(false); |
| 65 | }); |
| 66 | |
| 67 | it("ignores seconds", () => { |
| 68 | expect(cronMatches("*/5 * * * *", d("2026-10-12T10:05:42Z"))).toBe(true); |
| 69 | }); |
| 70 | }); |
| 71 | |
| 72 | describe("nextCronMatch", () => { |
| 73 | it("finds the next minute strictly after the given date", () => { |
| 74 | expect(nextCronMatch("*/15 * * * *", d("2026-10-12T10:00:00Z"))?.toISOString()).toBe("2026-10-12T10:15:00.000Z"); |
| 75 | expect(nextCronMatch("0 9 * * MON", d("2026-10-12T09:00:00Z"))?.toISOString()).toBe("2026-10-19T09:00:00.000Z"); |
| 76 | expect(nextCronMatch("0 0 29 2 *", d("2026-10-12T00:00:00Z"))?.toISOString()).toBe("2028-02-29T00:00:00.000Z"); |
| 77 | }); |
| 78 | |
| 79 | it("returns null when it never matches", () => { |
| 80 | expect(nextCronMatch("0 0 31 2 *", d("2026-10-12T00:00:00Z"))).toBe(null); |
| 81 | }); |
| 82 | }); |