mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
183 lines
5.9 KiB
TypeScript
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);
|
|
});
|
|
});
|