import { describe, expect, it } from "vitest"; import { parseRRule, nextOccurrence, validateMinInterval, validateCronExpression, isCronRule, stripCronPrefix, CRON_PREFIX, MIN_INTERVAL_MS, } from "./rrule.js"; describe("parseRRule", () => { it("accepts a daily rule", () => { expect(() => parseRRule("FREQ=DAILY;BYHOUR=9;BYMINUTE=0")).not.toThrow(); }); it("rejects invalid syntax", () => { expect(() => parseRRule("not-a-rule")).toThrow(); }); }); describe("nextOccurrence", () => { it("returns the next firing time after `after`", () => { const rule = "FREQ=DAILY;BYHOUR=9;BYMINUTE=0"; const after = new Date("2026-05-03T08:00:00Z"); const next = nextOccurrence(rule, "Asia/Kuala_Lumpur", after); expect(next).toBeInstanceOf(Date); expect(next!.getTime()).toBeGreaterThan(after.getTime()); }); it("returns null when the rule has no further occurrences", () => { const past = "DTSTART:20200101T000000Z\nRRULE:FREQ=DAILY;COUNT=1"; expect(nextOccurrence(past, "Asia/Kuala_Lumpur", new Date())).toBeNull(); }); }); describe("validateMinInterval", () => { it("accepts a daily rule (interval > 5 min)", () => { expect(validateMinInterval("FREQ=DAILY;BYHOUR=9;BYMINUTE=0", "Asia/Kuala_Lumpur")) .toEqual({ ok: true }); }); it("rejects a rule firing every minute", () => { const result = validateMinInterval("FREQ=MINUTELY", "Asia/Kuala_Lumpur"); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toMatch(/minimum interval/i); } }); }); describe("MIN_INTERVAL_MS", () => { it("equals 5 minutes", () => { expect(MIN_INTERVAL_MS).toBe(5 * 60 * 1000); }); }); describe("cron prefix detection", () => { it("CRON_PREFIX is 'CRON:'", () => { expect(CRON_PREFIX).toBe("CRON:"); }); it("isCronRule recognises the prefix", () => { expect(isCronRule("CRON:0 9 * * *")).toBe(true); expect(isCronRule("FREQ=DAILY")).toBe(false); expect(isCronRule("")).toBe(false); }); it("stripCronPrefix removes the prefix when present", () => { expect(stripCronPrefix("CRON:0 9 * * *")).toBe("0 9 * * *"); // Idempotent for non-cron rules. expect(stripCronPrefix("FREQ=DAILY")).toBe("FREQ=DAILY"); }); }); describe("nextOccurrence with cron rules", () => { it("dispatches CRON: rules to cron-parser and returns the next match", () => { // 9:00 every weekday in Asia/Kuala_Lumpur. const after = new Date("2026-05-09T08:00:00Z"); // Sat const next = nextOccurrence("CRON:0 9 * * 1-5", "Asia/Kuala_Lumpur", after); expect(next).toBeInstanceOf(Date); // Next weekday at 9 AM KL is Mon 2026-05-11 09:00 KL → 01:00 UTC. expect(next!.toISOString()).toBe("2026-05-11T01:00:00.000Z"); }); it("multi-line CRON rules return the EARLIEST next match across all lines", () => { // Two schedules joined: every Monday 9 AM KL + every Friday 5 PM KL. // Starting Saturday 2026-05-09 — the next match should be the Monday. const rule = "CRON:0 9 * * 1\n0 17 * * 5"; const next = nextOccurrence(rule, "Asia/Kuala_Lumpur", new Date("2026-05-09T08:00:00Z")); expect(next!.toISOString()).toBe("2026-05-11T01:00:00.000Z"); // Mon 09:00 KL // Now ask after Tuesday — Friday 5 PM KL should win. const nextFri = nextOccurrence( rule, "Asia/Kuala_Lumpur", new Date("2026-05-12T08:00:00Z"), ); expect(nextFri!.toISOString()).toBe("2026-05-15T09:00:00.000Z"); // Fri 17:00 KL = 09:00 UTC }); it("multi-line CRON rules ignore malformed lines and use the rest", () => { const rule = "CRON:not a cron\n0 9 * * 1-5"; const next = nextOccurrence( rule, "Asia/Kuala_Lumpur", new Date("2026-05-09T08:00:00Z"), ); expect(next).not.toBeNull(); }); it("still handles RRULE rules unchanged", () => { const next = nextOccurrence( "FREQ=DAILY;BYHOUR=9;BYMINUTE=0", "Asia/Kuala_Lumpur", new Date("2026-05-03T08:00:00Z"), ); expect(next).toBeInstanceOf(Date); }); }); describe("validateMinInterval with cron rules", () => { it("accepts an hourly cron (interval > 5 min)", () => { expect(validateMinInterval("CRON:0 * * * *", "Asia/Kuala_Lumpur")).toEqual({ ok: true }); }); it("rejects a cron firing every minute", () => { const r = validateMinInterval("CRON:* * * * *", "Asia/Kuala_Lumpur"); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toMatch(/minimum interval|fires every/i); }); it("rejects a malformed cron string with a useful message", () => { const r = validateMinInterval("CRON:not a cron", "Asia/Kuala_Lumpur"); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason.toLowerCase()).toContain("invalid cron"); }); it("validates every line of a multi-line cron rule", () => { expect( validateMinInterval("CRON:0 9 * * 1\n0 17 * * 5", "Asia/Kuala_Lumpur"), ).toEqual({ ok: true }); // Any single line firing too often fails the whole rule. const r = validateMinInterval("CRON:0 9 * * 1\n* * * * *", "Asia/Kuala_Lumpur"); expect(r.ok).toBe(false); }); }); describe("validateCronExpression", () => { it("returns null for a valid cron expression", () => { expect(validateCronExpression("0 9 * * 1-5", "Asia/Kuala_Lumpur")).toBe(null); }); it("returns an error string for malformed input", () => { const err = validateCronExpression("not a cron", "Asia/Kuala_Lumpur"); expect(typeof err).toBe("string"); }); });