Files
Termix/plugins/automations/tests/backend/cron.test.ts
T

183 lines
5.9 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
computeNextDueAt,
isValidCron,
isValidTimezone,
nextCronRun,
parseCron,
} from "../../src/backend/cron.js";
describe("parseCron", () => {
it("rejects anything that is not five fields", () => {
expect(() => parseCron("* * * *")).toThrow(/five fields/);
expect(() => parseCron("* * * * * *")).toThrow(/five fields/);
});
it("expands wildcards, lists, ranges and steps", () => {
const fields = parseCron("0,30 9-17 * * 1-5");
expect([...fields.minutes]).toEqual([0, 30]);
expect([...fields.hours]).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
expect([...fields.daysOfWeek]).toEqual([1, 2, 3, 4, 5]);
expect(fields.dowRestricted).toBe(true);
expect(fields.domRestricted).toBe(false);
});
it("supports step syntax", () => {
expect([...parseCron("*/15 * * * *").minutes]).toEqual([0, 15, 30, 45]);
});
it("accepts month and day names", () => {
expect([...parseCron("0 0 1 jan *").months]).toEqual([1]);
expect([...parseCron("0 0 * * sun").daysOfWeek]).toEqual([0]);
});
it("treats day 7 as Sunday", () => {
expect([...parseCron("0 0 * * 7").daysOfWeek]).toEqual([0]);
});
it("rejects out of range values", () => {
expect(() => parseCron("60 * * * *")).toThrow(/out of range/);
expect(() => parseCron("* 24 * * *")).toThrow(/out of range/);
expect(() => parseCron("* * 0 * *")).toThrow(/out of range/);
});
it("reports validity without throwing", () => {
expect(isValidCron("*/5 * * * *")).toBe(true);
expect(isValidCron("nonsense")).toBe(false);
});
});
describe("nextCronRun", () => {
it("finds the next matching minute", () => {
const from = new Date(2026, 0, 1, 10, 3, 30);
expect(nextCronRun("*/15 * * * *", from)).toEqual(
new Date(2026, 0, 1, 10, 15, 0, 0),
);
});
it("never returns the starting minute", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
expect(nextCronRun("0 * * * *", from)).toEqual(
new Date(2026, 0, 1, 11, 0, 0, 0),
);
});
it("rolls into the next day", () => {
const from = new Date(2026, 0, 1, 23, 45, 0);
expect(nextCronRun("0 2 * * *", from)).toEqual(
new Date(2026, 0, 2, 2, 0, 0, 0),
);
});
it("unions day-of-month and day-of-week when both are set", () => {
// The 15th, or any Monday.
const from = new Date(2026, 0, 1, 0, 0, 0);
const next = nextCronRun("0 0 15 * 1", from);
expect(next).not.toBeNull();
const isFifteenth = next!.getDate() === 15;
const isMonday = next!.getDay() === 1;
expect(isFifteenth || isMonday).toBe(true);
});
it("gives up on a date that can never match", () => {
expect(nextCronRun("0 0 30 2 *", new Date(2026, 0, 1))).toBeNull();
});
});
describe("computeNextDueAt", () => {
it("prefers an interval over a cron expression", () => {
const from = new Date("2026-01-01T00:00:00.000Z");
expect(
computeNextDueAt({ intervalSeconds: 300, cron: "0 0 * * *" }, from),
).toBe("2026-01-01T00:05:00.000Z");
});
it("falls back to cron", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
const due = computeNextDueAt({ cron: "30 10 * * *" }, from);
expect(due).toBe(new Date(2026, 0, 1, 10, 30, 0, 0).toISOString());
});
it("returns null when nothing is scheduled", () => {
expect(computeNextDueAt({})).toBeNull();
});
it("passes the zone through to the cron evaluation", () => {
// 02:00 in Tokyo on 2026-06-02 is 17:00 UTC on 2026-06-01.
const from = new Date("2026-06-01T00:00:00.000Z");
expect(
computeNextDueAt({ cron: "0 2 * * *", timezone: "Asia/Tokyo" }, from),
).toBe("2026-06-01T17:00:00.000Z");
});
it("ignores the zone for interval schedules", () => {
const from = new Date("2026-01-01T00:00:00.000Z");
expect(
computeNextDueAt({ intervalSeconds: 600, timezone: "Asia/Tokyo" }, from),
).toBe("2026-01-01T00:10:00.000Z");
});
});
describe("time zone handling", () => {
it("resolves a daily cron against the given zone", () => {
const from = new Date("2026-06-01T00:00:00.000Z");
// 09:30 New York in June (UTC-4) is 13:30 UTC.
const next = nextCronRun("30 9 * * *", from, "America/New_York");
expect(next?.toISOString()).toBe("2026-06-01T13:30:00.000Z");
});
it("tracks daylight saving, so the UTC instant shifts by an hour", () => {
const summer = nextCronRun(
"0 12 * * *",
new Date("2026-07-01T00:00:00.000Z"),
"America/New_York",
);
const winter = nextCronRun(
"0 12 * * *",
new Date("2026-01-01T00:00:00.000Z"),
"America/New_York",
);
// Noon local both times, but UTC-4 in July and UTC-5 in January.
expect(summer?.toISOString()).toBe("2026-07-01T16:00:00.000Z");
expect(winter?.toISOString()).toBe("2026-01-01T17:00:00.000Z");
});
it("matches the day of week in the target zone, not the server's", () => {
// 23:00 UTC Sunday is already Monday in Tokyo.
const next = nextCronRun(
"0 8 * * mon",
new Date("2026-06-07T22:00:00.000Z"),
"Asia/Tokyo",
);
expect(next?.toISOString()).toBe("2026-06-07T23:00:00.000Z");
});
it("falls back to server time for an unknown zone instead of throwing", () => {
const from = new Date(2026, 0, 1, 10, 0, 0);
const next = nextCronRun("30 10 * * *", from, "Not/AZone");
expect(next).toEqual(new Date(2026, 0, 1, 10, 30, 0, 0));
});
it("handles midnight, which some hour cycles format as 24", () => {
const next = nextCronRun(
"0 0 * * *",
new Date("2026-06-01T10:00:00.000Z"),
"UTC",
);
expect(next?.toISOString()).toBe("2026-06-02T00:00:00.000Z");
});
});
describe("isValidTimezone", () => {
it("accepts real zones", () => {
expect(isValidTimezone("UTC")).toBe(true);
expect(isValidTimezone("Europe/London")).toBe(true);
});
it("rejects made-up ones", () => {
expect(isValidTimezone("Middle/Earth")).toBe(false);
expect(isValidTimezone("")).toBe(false);
});
});