Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
99 lines
3.3 KiB
TypeScript
99 lines
3.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { parseCron, cronMatchesTime } from "./cron.js";
|
|
import { ImpossibleScheduleError, InvalidCronError } from "./errors.js";
|
|
|
|
describe("parseCron — valid syntax", () => {
|
|
it("parses the every-minute expression", () => {
|
|
const c = parseCron("* * * * *");
|
|
expect(c.minute.values.size).toBe(60);
|
|
expect(c.minute.restricted).toBe(false);
|
|
expect(c.dayOfMonth.restricted).toBe(false);
|
|
expect(c.dayOfWeek.restricted).toBe(false);
|
|
});
|
|
it("parses steps, ranges and lists", () => {
|
|
const c = parseCron("*/15 8-18/2 * * 1,3,5");
|
|
expect([...c.minute.values]).toEqual([0, 15, 30, 45]);
|
|
expect([...c.hour.values]).toEqual([8, 10, 12, 14, 16, 18]);
|
|
expect([...c.dayOfWeek.values]).toEqual([1, 3, 5]);
|
|
expect(c.minute.restricted).toBe(true);
|
|
expect(c.hour.restricted).toBe(true);
|
|
expect(c.dayOfWeek.restricted).toBe(true);
|
|
});
|
|
it("parses month and dow names case-insensitively", () => {
|
|
const c = parseCron("0 0 1 jan-mar mon-fri");
|
|
expect([...c.month.values]).toEqual([1, 2, 3]);
|
|
expect([...c.dayOfWeek.values]).toEqual([1, 2, 3, 4, 5]);
|
|
});
|
|
it("accepts a list element of one value", () => {
|
|
expect([...parseCron("5,10 * * * *").minute.values]).toEqual([5, 10]);
|
|
});
|
|
it("treats n/step as n..max/step", () => {
|
|
expect([...parseCron("20/15 * * * *").minute.values]).toEqual([20, 35, 50]);
|
|
});
|
|
});
|
|
|
|
describe("parseCron — rejection", () => {
|
|
const bad = [
|
|
"",
|
|
" ",
|
|
"* * * *",
|
|
"* * * * * *",
|
|
"60 * * * *",
|
|
"* 24 * * *",
|
|
"* * 0 * *",
|
|
"* * 32 * *",
|
|
"* * * 0 *",
|
|
"* * * 13 *",
|
|
"* * * * 7",
|
|
"* * * * MON-BR",
|
|
"a * * * *",
|
|
"*/0 * * * *",
|
|
"*/-1 * * * *",
|
|
"5-1 * * * *",
|
|
"1--2 * * * *",
|
|
"@reboot",
|
|
"@daily",
|
|
"? * * * *",
|
|
"* * ? * *",
|
|
"* * * * * 2025",
|
|
"0 0 * * MON#1",
|
|
"0 0 L * *",
|
|
"0 0 1 W *",
|
|
"1/2/3 * * * *",
|
|
"*/ * * * *",
|
|
"1,,2 * * * *",
|
|
"-5 * * * *",
|
|
];
|
|
it.each(bad.map((e) => [e]))("rejects %s", (expr) => {
|
|
expect(() => parseCron(expr)).toThrow(InvalidCronError);
|
|
});
|
|
it("rejects non-string input", () => {
|
|
expect(() => parseCron(undefined as unknown as string)).toThrow(InvalidCronError);
|
|
expect(() => parseCron(5 as unknown as string)).toThrow(InvalidCronError);
|
|
});
|
|
it("rejects impossible schedules", () => {
|
|
expect(() => parseCron("0 0 31 2 *")).toThrow(ImpossibleScheduleError);
|
|
expect(() => parseCron("0 0 30 2 *")).toThrow(ImpossibleScheduleError);
|
|
expect(() => parseCron("0 0 31 4 *")).toThrow(ImpossibleScheduleError);
|
|
});
|
|
it("accepts Feb 29 (leap years exist)", () => {
|
|
expect(() => parseCron("0 0 29 2 *")).not.toThrow();
|
|
});
|
|
it("accepts the 31st in long months", () => {
|
|
expect(() => parseCron("0 0 31 1 *")).not.toThrow();
|
|
expect(() => parseCron("0 0 31 * *")).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("cronMatchesTime", () => {
|
|
const cron = parseCron("*/15 9 * * *");
|
|
it("matches on the quarter hour in the 9 hour", () => {
|
|
expect(cronMatchesTime(cron, 9, 0, 0)).toBe(true);
|
|
expect(cronMatchesTime(cron, 9, 45, 0)).toBe(true);
|
|
});
|
|
it("rejects other minutes/hours and any nonzero second", () => {
|
|
expect(cronMatchesTime(cron, 9, 20, 0)).toBe(false);
|
|
expect(cronMatchesTime(cron, 10, 0, 0)).toBe(false);
|
|
expect(cronMatchesTime(cron, 9, 0, 1)).toBe(false);
|
|
});
|
|
});
|