T-HUB/packages/scheduling/src/rematerialize.test.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
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/.
2026-10-10 11:56:42 +00:00

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();
});
});