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/.
198 lines
9 KiB
TypeScript
198 lines
9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { rematerialize } from "./rematerialize.js";
|
|
import { nextOccurrences } from "./occurrences_extra.js";
|
|
import type { StoredOccurrence } from "./rematerialize.js";
|
|
|
|
/** Build stored rows as a worker would have persisted them. */
|
|
function storedFrom(spec: { cron: string; timezone: string }, from: string, count: number): StoredOccurrence[] {
|
|
return nextOccurrences(spec, from, count).map((o) => ({
|
|
nominalLocal: o.nominalLocal,
|
|
fireAtUtc: o.fireAtUtc,
|
|
claimed: false,
|
|
}));
|
|
}
|
|
|
|
describe("rematerialize — invalidation and regeneration", () => {
|
|
it("deletes unclaimed future occurrences and regenerates from the change instant", () => {
|
|
const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
|
const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5);
|
|
// One past occurrence was claimed.
|
|
stored[0] = { ...stored[0]!, claimed: true };
|
|
const changeInstant = "2024-03-02T00:00:00Z";
|
|
|
|
const result = rematerialize(oldSpec, { cron: "0 10 * * *", timezone: "America/New_York" }, stored, {
|
|
changeInstant,
|
|
lastFiredNominalLocal: stored[0]!.nominalLocal,
|
|
count: 3,
|
|
});
|
|
|
|
// Unclaimed future rows are invalidated.
|
|
expect(result.toDeleteFireAtUtc).toEqual(
|
|
stored.slice(1).map((r) => r.fireAtUtc),
|
|
);
|
|
// New occurrences start strictly after the change instant and the last claim.
|
|
expect(result.toInsert.length).toBe(3);
|
|
expect(result.toInsert[0]!.fireAtUtc).toBeGreaterThan(Date.parse(changeInstant));
|
|
expect(result.toInsert[0]!.nominalLocal).toBe("2024-03-02T10:00");
|
|
// The new tzdata version is reported for recording.
|
|
expect(result.tzdataVersion).toMatch(/.+/);
|
|
});
|
|
|
|
it("never refires a nominal slot already fired under the old zone (NY -> LA same day)", () => {
|
|
// Daily 09:00. Under New York the 2024-03-01 09:00 slot fired at 14:00Z.
|
|
const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
|
const stored: StoredOccurrence[] = [
|
|
{ nominalLocal: "2024-03-01T09:00", fireAtUtc: Date.parse("2024-03-01T14:00:00Z"), claimed: true },
|
|
...storedFrom(oldSpec, "2024-03-02T00:00:00Z", 3),
|
|
];
|
|
const result = rematerialize(oldSpec, { cron: "0 9 * * *", timezone: "America/Los_Angeles" }, stored, {
|
|
changeInstant: "2024-03-01T15:00:00Z", // later the same day, after the NY firing
|
|
lastFiredNominalLocal: "2024-03-01T09:00",
|
|
count: 3,
|
|
});
|
|
// 09:00 in Los Angeles on 03-01 is 17:00Z — but that nominal slot was
|
|
// already fired under New York, so it must NOT appear.
|
|
expect(result.toInsert.map((o) => o.nominalLocal)).not.toContain("2024-03-01T09:00");
|
|
expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([
|
|
"2024-03-02T09:00",
|
|
"2024-03-03T09:00",
|
|
"2024-03-04T09:00",
|
|
].slice(0, 3));
|
|
expect(result.toInsert[0]!.fireAtUtc).toBe(Date.parse("2024-03-02T17:00:00Z"));
|
|
});
|
|
|
|
it("claimed rows are never deleted", () => invariants());
|
|
|
|
function invariants(): boolean {
|
|
const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
|
const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5);
|
|
stored[0] = { ...stored[0]!, claimed: true };
|
|
const result = rematerialize(oldSpec, oldSpec, stored, {
|
|
changeInstant: "2024-03-02T00:00:00Z",
|
|
lastFiredNominalLocal: stored[0]!.nominalLocal,
|
|
count: 3,
|
|
});
|
|
expect(result.toDeleteFireAtUtc).not.toContain(stored[0]!.fireAtUtc);
|
|
return true;
|
|
}
|
|
|
|
it("inserts never collide with SURVIVING rows (deletes are applied first)", () => {
|
|
const oldSpec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
|
const stored = storedFrom(oldSpec, "2024-03-01T00:00:00Z", 5);
|
|
stored[0] = { ...stored[0]!, claimed: true };
|
|
const result = rematerialize(oldSpec, oldSpec, stored, {
|
|
changeInstant: "2024-03-01T00:00:00Z",
|
|
lastFiredNominalLocal: stored[0]!.nominalLocal,
|
|
count: 10,
|
|
});
|
|
const deleting = new Set(result.toDeleteFireAtUtc);
|
|
const surviving = stored.filter((r) => !deleting.has(r.fireAtUtc)).map((r) => r.fireAtUtc);
|
|
expect(surviving).toEqual([stored[0]!.fireAtUtc]); // only the claimed row survives
|
|
for (const o of result.toInsert) {
|
|
expect(surviving.includes(o.fireAtUtc)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("unchanged spec regenerates the same instants it deleted (idempotent)", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
|
const stored = storedFrom(spec, "2024-03-01T00:00:00Z", 5);
|
|
stored[0] = { ...stored[0]!, claimed: true };
|
|
const result = rematerialize(spec, spec, stored, {
|
|
changeInstant: "2024-03-01T00:00:00Z",
|
|
lastFiredNominalLocal: stored[0]!.nominalLocal,
|
|
count: stored.length - 1, // regenerate exactly the deleted horizon
|
|
});
|
|
const deletedSorted = [...result.toDeleteFireAtUtc].sort((a, b) => a - b);
|
|
expect(result.toInsert.map((o) => o.fireAtUtc)).toEqual(deletedSorted);
|
|
});
|
|
|
|
it("toInsert fire instants are unique among themselves and cross the gap once", () => {
|
|
const spec = { cron: "*/15 * * * *", timezone: "America/New_York" };
|
|
const stored = storedFrom(spec, "2024-03-08T00:00:00Z", 20);
|
|
// 150 quarter-hours from 03-09 00:15Z reaches past the 03-10 07:00Z edge.
|
|
const result = rematerialize(spec, spec, stored, { changeInstant: "2024-03-09T00:00:00Z", count: 150 });
|
|
const instants = result.toInsert.map((o) => o.fireAtUtc);
|
|
expect(new Set(instants).size).toBe(instants.length);
|
|
const edge = instants.filter((i) => i === Date.parse("2024-03-10T07:00:00Z"));
|
|
expect(edge.length).toBe(1);
|
|
// The nominal slots 02:00..02:45 collapse onto that one instant, and the
|
|
// surviving occurrence carries the latest collapsed nominal slot.
|
|
const atEdge = result.toInsert.find((o) => o.fireAtUtc === Date.parse("2024-03-10T07:00:00Z"));
|
|
expect(atEdge?.nominalLocal).toBe("2024-03-10T03:00");
|
|
});
|
|
|
|
it("tzdata-only change (same zone) still regenerates correctly", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "America/New_York" };
|
|
const stored = storedFrom(spec, "2024-03-01T00:00:00Z", 3);
|
|
const result = rematerialize(spec, spec, stored, {
|
|
changeInstant: "2024-03-01T00:00:00Z",
|
|
tzdataVersion: "2025a",
|
|
count: 2,
|
|
});
|
|
expect(result.tzdataVersion).toBe("2025a");
|
|
expect(result.toInsert.length).toBe(2);
|
|
});
|
|
|
|
it("reports the current tzdata version when not overridden", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
|
const result = rematerialize(spec, spec, [], { changeInstant: "2024-01-01T00:00:00Z" });
|
|
expect(typeof result.tzdataVersion).toBe("string");
|
|
expect(result.tzdataVersion.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("a schedule with no history regenerates from the change instant alone", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
|
const result = rematerialize(spec, { ...spec, timezone: "Europe/Berlin" }, [], {
|
|
changeInstant: "2024-03-05T00:00:00Z",
|
|
count: 2,
|
|
});
|
|
expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([
|
|
"2024-03-05T09:00",
|
|
"2024-03-06T09:00",
|
|
]);
|
|
expect(result.toDeleteFireAtUtc).toEqual([]);
|
|
});
|
|
|
|
it("unclaimed PAST occurrences are not deleted (only future ones)", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
|
const stored: StoredOccurrence[] = [
|
|
{ nominalLocal: "2024-02-01T09:00", fireAtUtc: Date.parse("2024-02-01T09:00:00Z"), claimed: false },
|
|
{ nominalLocal: "2024-04-01T09:00", fireAtUtc: Date.parse("2024-04-01T09:00:00Z"), claimed: false },
|
|
];
|
|
const result = rematerialize(spec, spec, stored, { changeInstant: "2024-03-01T00:00:00Z" });
|
|
expect(result.toDeleteFireAtUtc).toEqual([Date.parse("2024-04-01T09:00:00Z")]);
|
|
});
|
|
|
|
it("survives a whole-day skip: Apia daily 09:00 across the 2011 change", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "Pacific/Apia" };
|
|
const stored = storedFrom(spec, "2011-12-28T00:00:00Z", 3);
|
|
const result = rematerialize(spec, spec, stored, {
|
|
changeInstant: "2011-12-29T12:00:00Z",
|
|
lastFiredNominalLocal: "2011-12-28T09:00",
|
|
count: 2,
|
|
});
|
|
expect(result.toInsert.map((o) => o.nominalLocal)).toEqual([
|
|
"2011-12-29T09:00",
|
|
"2011-12-30T09:00", // skipped day, shifted forward
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("rematerialize — input validation", () => {
|
|
it("rejects an invalid new zone", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
|
expect(() =>
|
|
rematerialize(spec, { cron: "0 9 * * *", timezone: "local" }, [], { changeInstant: "2024-01-01T00:00:00Z" }),
|
|
).toThrow();
|
|
});
|
|
it("rejects an invalid new cron", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
|
expect(() =>
|
|
rematerialize(spec, { cron: "99 * * * *", timezone: "UTC" }, [], { changeInstant: "2024-01-01T00:00:00Z" }),
|
|
).toThrow();
|
|
});
|
|
it("rejects an offset-less change instant", () => {
|
|
const spec = { cron: "0 9 * * *", timezone: "UTC" };
|
|
expect(() => rematerialize(spec, spec, [], { changeInstant: "2024-01-01T00:00:00" })).toThrow();
|
|
});
|
|
});
|