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/.
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { nextOccurrences, materializeOccurrences } from "./occurrences_extra.js";
|
|
import { prepareSchedule } from "./schedule.js";
|
|
import { nextOccurrencesPrepared } from "./occurrences.js";
|
|
import { InvalidCronError } from "./errors.js";
|
|
|
|
const ISO = (occ: { fireAtUtc: number }) => new Date(occ.fireAtUtc).toISOString().replace(".000Z", "Z");
|
|
|
|
describe("UTC passthrough", () => {
|
|
it("emits exactly the cron slots with no offset math", () => {
|
|
const out = nextOccurrences({ cron: "0 9 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 3);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-03-09T09:00:00Z",
|
|
"2024-03-10T09:00:00Z",
|
|
"2024-03-11T09:00:00Z",
|
|
]);
|
|
expect(out.map((o) => o.nominalLocal)).toEqual([
|
|
"2024-03-09T09:00",
|
|
"2024-03-10T09:00",
|
|
"2024-03-11T09:00",
|
|
]);
|
|
});
|
|
it("daily at 02:30 UTC is unaffected by any DST", () => {
|
|
const out = nextOccurrences({ cron: "30 2 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 3);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-03-09T02:30:00Z",
|
|
"2024-03-10T02:30:00",
|
|
"2024-03-11T02:30:00Z",
|
|
].map((x) => (x.endsWith("Z") ? x : `${x}Z`)));
|
|
});
|
|
it("respects hour and minute fields", () => {
|
|
const out = nextOccurrences({ cron: "15,45 6 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 4);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-03-09T06:15:00Z",
|
|
"2024-03-09T06:45:00Z",
|
|
"2024-03-10T06:15:00Z",
|
|
"2024-03-10T06:45:00Z",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("Europe/New_York spring-forward gap (2024-03-10 02:00->03:00)", () => {
|
|
it("daily 02:30: the gap day fires at the shifted-forward instant 03:00 local (07:00Z)", () => {
|
|
const out = nextOccurrences({ cron: "30 2 * * *", timezone: "America/New_York" }, "2024-03-08T00:00:00Z", 3);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-03-08T07:30:00Z", // 02:30 EST
|
|
"2024-03-09T07:30:00Z", // 02:30 EST
|
|
"2024-03-10T07:00:00Z", // 02:30 does not exist; fires 03:00 EDT
|
|
]);
|
|
// The nominal slot is preserved on the gap day even though the instant
|
|
// shifted — that is what last_fired_nominal_local records.
|
|
expect(out[2]!.nominalLocal).toBe("2024-03-10T02:30");
|
|
});
|
|
it("a 02:00-only schedule still fires on the gap day, shifted to 03:00 local", () => {
|
|
const out = nextOccurrences({ cron: "0 2 * * *", timezone: "America/New_York" }, "2024-03-09T00:00:00Z", 3);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-03-09T07:00:00Z", // 02:00 EST
|
|
"2024-03-10T07:00:00Z", // 02:00 nonexistent -> 03:00 EDT
|
|
"2024-03-11T06:00:00Z", // 02:00 EDT
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("collapse-dedup: */15 across the spring-forward hour fires once per instant", () => {
|
|
it("02:00..02:45 nominal slots collapse; the gap instant appears exactly once", () => {
|
|
const out = nextOccurrences(
|
|
{ cron: "*/15 * * * *", timezone: "America/New_York" },
|
|
"2024-03-10T05:00:00Z",
|
|
8,
|
|
);
|
|
// 03:00 EDT == 07:00Z is the first valid instant after the gap. The slots
|
|
// 02:00, 02:15, 02:30, 02:45 all materialize there -> ONE occurrence.
|
|
// 05:00Z == 00:15 local. The next slots are 00:30(05:30Z), 00:45(05:45Z),
|
|
// 01:00(06:00Z), 01:15(06:15Z), then the four nominal slots 01:30(06:30Z),
|
|
// 01:45(06:45Z), 02:00, 02:15, 02:30, 02:45 — of which everything in the
|
|
// gap collapses onto 03:00 local == 07:00Z — ONE occurrence — followed by
|
|
// the normal 03:15 cadence.
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-03-10T05:15:00Z",
|
|
"2024-03-10T05:30:00Z",
|
|
"2024-03-10T05:45:00Z",
|
|
"2024-03-10T06:00:00Z",
|
|
"2024-03-10T06:15:00Z",
|
|
"2024-03-10T06:30:00Z",
|
|
"2024-03-10T06:45:00Z",
|
|
"2024-03-10T07:00:00Z",
|
|
]);
|
|
// The single surviving occurrence at the gap edge carries the LATEST
|
|
// nominal slot that collapsed onto it — the conservative never-refire label.
|
|
expect(out[7]!.nominalLocal).toBe("2024-03-10T03:00");
|
|
});
|
|
it("the collapse instant appears exactly once even when asking for many", () => {
|
|
const out = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-10T05:00:00Z", 40);
|
|
const atEdge = out.filter((o) => o.fireAtUtc === Date.parse("2024-03-10T07:00:00Z"));
|
|
expect(atEdge.length).toBe(1);
|
|
});
|
|
it("produces globally unique fire instants (occurrence key uniqueness)", () => {
|
|
const out = nextOccurrences({ cron: "*/15 * * * *", timezone: "America/New_York" }, "2024-03-09T00:00:00Z", 200);
|
|
const instants = out.map((o) => o.fireAtUtc);
|
|
expect(new Set(instants).size).toBe(instants.length);
|
|
});
|
|
it("fold hour: */15 emits each ambiguous quarter-hour once, at the earlier instant", () => {
|
|
const out = nextOccurrences(
|
|
{ cron: "*/15 * * * *", timezone: "America/New_York" },
|
|
"2024-11-03T04:00:00Z",
|
|
8,
|
|
{ exclusive: false },
|
|
);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-11-03T04:00:00Z", // 00:00 EDT
|
|
"2024-11-03T04:15:00Z",
|
|
"2024-11-03T04:30:00Z",
|
|
"2024-11-03T04:45:00Z",
|
|
"2024-11-03T05:00:00Z", // 01:00 EDT (earlier reading)
|
|
"2024-11-03T05:15:00Z",
|
|
"2024-11-03T05:30:00Z",
|
|
"2024-11-03T05:45:00Z",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("fall-back fold — daily 01:30 fires once at the earlier instant", () => {
|
|
it("fires 05:30Z (EDT), never 06:30Z, and continues next day", () => {
|
|
const out = nextOccurrences({ cron: "30 1 * * *", timezone: "America/New_York" }, "2024-11-01T00:00:00Z", 3);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-11-01T05:30:00Z", // 01:30 EDT
|
|
"2024-11-02T05:30:00Z", // 01:30 EDT
|
|
"2024-11-03T05:30:00Z", // 01:30 exists twice; fires ONCE at EDT (05:30Z)
|
|
]);
|
|
// and never the later EST reading
|
|
expect(out.map(ISO)).not.toContain("2024-11-03T06:30:00Z");
|
|
});
|
|
});
|
|
|
|
describe("cron day-of-month/month handling", () => {
|
|
it("monthly on the 1st at 09:00", () => {
|
|
const out = nextOccurrences({ cron: "0 9 1 * *", timezone: "UTC" }, "2024-01-15T00:00:00Z", 3);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-02-01T09:00:00Z",
|
|
"2024-03-01T09:00:00Z",
|
|
"2024-04-01T09:00:00Z",
|
|
]);
|
|
});
|
|
it("monthly last-day-ish: the 31st only in long months", () => {
|
|
const out = nextOccurrences({ cron: "0 9 31 * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 4);
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-01-31T09:00:00Z",
|
|
"2024-03-31T09:00:00Z",
|
|
"2024-05-31T09:00:00Z",
|
|
"2024-07-31T09:00:00Z",
|
|
]);
|
|
});
|
|
it("day-of-week: weekly on Monday 09:00", () => {
|
|
const out = nextOccurrences({ cron: "0 9 * * 1", timezone: "UTC" }, "2024-03-08T00:00:00Z", 3);
|
|
// 2024-03-08 is a Friday and does not match; the Mondays follow.
|
|
expect(out.map(ISO)).toEqual([
|
|
"2024-03-11T09:00:00Z",
|
|
"2024-03-18T09:00:00Z",
|
|
"2024-03-25T09:00:00Z",
|
|
]);
|
|
expect(out.every((o) => new Date(o.fireAtUtc).getUTCDay() === 1)).toBe(true);
|
|
});
|
|
it("dom/dow OR rule: both restricted means either matches", () => {
|
|
// 13th OR every Friday (2024-03-01 is itself a Friday but sits exactly at
|
|
// `from`, so the exclusive default drops it; 03-02 would keep it).
|
|
const out = nextOccurrences({ cron: "0 0 13 * 5", timezone: "UTC" }, "2024-03-01T00:00:00Z", 6);
|
|
expect(out.map((o) => o.nominalLocal)).toEqual([
|
|
"2024-03-08T00:00",
|
|
"2024-03-13T00:00",
|
|
"2024-03-15T00:00",
|
|
"2024-03-22T00:00",
|
|
"2024-03-29T00:00",
|
|
"2024-04-05T00:00",
|
|
]);
|
|
});
|
|
it("dow-only restriction drives the day when dom is *", () => {
|
|
// Mondays and Fridays only
|
|
const out = nextOccurrences({ cron: "0 12 * * 1,5", timezone: "UTC" }, "2024-03-04T00:00:00Z", 4);
|
|
expect(out.map((o) => o.nominalLocal)).toEqual([
|
|
"2024-03-04T12:00", // Monday
|
|
"2024-03-08T12:00", // Friday
|
|
"2024-03-11T12:00", // Monday
|
|
"2024-03-15T12:00", // Friday
|
|
]);
|
|
});
|
|
it("hour range with step: 8-18/2 hours", () => {
|
|
const out = nextOccurrences({ cron: "0 8-18/2 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 7);
|
|
expect(out.map((o) => o.nominalLocal.slice(11))).toEqual(["08:00", "10:00", "12:00", "14:00", "16:00", "18:00", "08:00"]);
|
|
});
|
|
it("month names and dow names", () => {
|
|
const out = nextOccurrences({ cron: "0 9 1 JAN-MAR *", timezone: "UTC" }, "2024-01-02T00:00:00Z", 2);
|
|
expect(out.map(ISO)).toEqual(["2024-02-01T09:00:00Z", "2024-03-01T09:00:00Z"]);
|
|
});
|
|
});
|
|
|
|
describe("Pacific/Apia whole-day skip in generation", () => {
|
|
it("daily 09:00 skips 2011-12-30 entirely and resumes 12-31", () => {
|
|
const out = nextOccurrences({ cron: "0 9 * * *", timezone: "Pacific/Apia" }, "2011-12-28T00:00:00Z", 3);
|
|
expect(out.map((o) => o.nominalLocal)).toEqual([
|
|
"2011-12-28T09:00",
|
|
"2011-12-29T09:00",
|
|
// The skipped day's slot (12-30 09:00) still fires — shifted forward to
|
|
// the first valid instant — and keeps its own nominal label so it can
|
|
// never be refired as a "new" slot later.
|
|
"2011-12-30T09:00",
|
|
]);
|
|
// Its fire instant is the zone's first valid instant after the skip.
|
|
expect(new Date(out[2]!.fireAtUtc).toISOString()).toBe("2011-12-30T10:00:00.000Z");
|
|
});
|
|
});
|
|
|
|
describe("generation hygiene", () => {
|
|
it("prepared and unprepared agree", () => {
|
|
const spec = { cron: "*/20 * * * *", timezone: "America/New_York" };
|
|
const a = nextOccurrences(spec, "2024-03-09T00:00:00Z", 50);
|
|
const b = nextOccurrencesPrepared(prepareSchedule(spec), "2024-03-09T00:00:00Z", 50);
|
|
expect(a).toEqual(b);
|
|
});
|
|
it("exclusive default: regenerating from the last emitted instant does not re-emit it", () => {
|
|
const spec = { cron: "0 * * * *", timezone: "UTC" };
|
|
const first = nextOccurrences(spec, "2024-03-09T00:00:00Z", 5);
|
|
const last = first[first.length - 1]!;
|
|
const second = nextOccurrences(spec, last.fireAtUtc, 5);
|
|
expect(second[0]!.fireAtUtc).toBeGreaterThan(last.fireAtUtc);
|
|
expect(second.map(ISO)).toEqual([
|
|
"2024-03-09T06:00:00Z",
|
|
"2024-03-09T07:00:00Z",
|
|
"2024-03-09T08:00:00Z",
|
|
"2024-03-09T09:00:00Z",
|
|
"2024-03-09T10:00:00Z",
|
|
]);
|
|
});
|
|
it("inclusive option includes a slot exactly at from", () => {
|
|
const out = nextOccurrences({ cron: "0 * * * *", timezone: "UTC" }, "2024-03-09T05:00:00Z", 2, { exclusive: false });
|
|
expect(out.map(ISO)).toEqual(["2024-03-09T05:00:00Z", "2024-03-09T06:00:00Z"]);
|
|
});
|
|
it("rejects a bad count", () => {
|
|
expect(() => nextOccurrences({ cron: "* * * * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 0)).toThrow(RangeError);
|
|
expect(() => nextOccurrences({ cron: "* * * * *", timezone: "UTC" }, "2024-01-01T00:00:00Z", 1.5)).toThrow(RangeError);
|
|
});
|
|
it("materializeOccurrences is the generation batch", () => {
|
|
const out = materializeOccurrences({ cron: "0 9 * * *", timezone: "UTC" }, "2024-03-09T00:00:00Z", 2);
|
|
expect(out.map(ISO)).toEqual(["2024-03-09T09:00:00Z", "2024-03-10T09:00:00Z"]);
|
|
});
|
|
it("rejects invalid cron/zone at generation time", () => {
|
|
expect(() => nextOccurrences({ cron: "not cron", timezone: "UTC" }, "2024-01-01T00:00:00Z", 1)).toThrow(InvalidCronError);
|
|
expect(() => nextOccurrences({ cron: "* * * * *", timezone: "local" }, "2024-01-01T00:00:00Z", 1)).toThrow();
|
|
});
|
|
});
|