diff --git a/src/notifier.test.ts b/src/notifier.test.ts new file mode 100644 index 0000000..36aa4cd --- /dev/null +++ b/src/notifier.test.ts @@ -0,0 +1,274 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { Channel } from "./channels/channel.ts"; +import type { Config } from "./config.ts"; +import { newNotifier } from "./notifier.ts"; +import type { OpenMeetClient, OpenMeetEvent } from "./openmeet.ts"; +import { TIMEZONE } from "./schedule.ts"; + +const zdt = (s: string) => Temporal.ZonedDateTime.from(`${s}[${TIMEZONE}]`); + +const ev = ( + slug: string, + startDate = "2026-06-10T18:00:00", + name = slug, + endDate = "2026-06-15T20:00:00", +): OpenMeetEvent => ({ name, slug, startDate, endDate }); + +// A Config with only the fields the notifier reads. The injected client +// ignores cfg, so the atproto/openmeet fields don't matter here. +const cfg = (overrides: Partial = {}): Config => + ({ + weeklyNotifyDay: 5, // Friday — far from the Monday "now" used below + weeklyNotifyHour: 9, + weeklyNotifyMinute: 0, + checkIntervalMs: 250, + ...overrides, + }) as Config; + +// Records every notifyOne/notifyMany call so a test can assert what was sent. +function recordingChannel(name = "rec"): Channel & { + ones: OpenMeetEvent[]; + manys: OpenMeetEvent[][]; +} { + const ones: OpenMeetEvent[] = []; + const manys: OpenMeetEvent[][] = []; + const ch: Channel & { ones: OpenMeetEvent[]; manys: OpenMeetEvent[][] } = { + name, + ones, + manys, + notifyOne: async (e) => { + ones.push(e); + return; + }, + notifyMany: async (es) => { + manys.push([...es]); + return; + }, + }; + return ch; +} + +// Scripts getUpcomingEvents across loop iterations. `onCall` runs before each +// scripted return (used to advance an injected clock). The aborter fires after +// the last scripted response so the start() loop exits deterministically. +function fakeClient( + scripts: OpenMeetEvent[][], + aborter: AbortController, + onCall?: (i: number) => void, +): OpenMeetClient { + let i = 0; + return { + getUpcomingEvents() { + onCall?.(i); + const events = scripts[i]!; + if (i >= scripts.length - 1) aborter.abort(); + i++; + return Promise.resolve(events); + }, + }; +} + +test("start: first fetch only primes the cache, then newly-seen slugs notify", async () => { + const aborter = new AbortController(); + const ch = recordingChannel(); + const client = fakeClient( + [ + [ev("a"), ev("b")], // prime + [ev("a"), ev("b"), ev("c")], // c is new + [ev("a"), ev("c")], // nothing new (b disappeared) + ], + aborter, + ); + + const notifier = await newNotifier({ + cfg: cfg(), + aborter, + channels: [ch], + omClient: client, + // Fixed Monday noon; weekly digest is Friday 09:00 so it never fires here. + now: () => zdt("2026-06-08T12:00:00"), + }); + + await notifier.start(); + + assert.deepEqual( + ch.ones.map((e) => e.slug), + ["c"], + "only the newly-seen event should be announced", + ); + assert.equal(ch.manys.length, 0, "no weekly digest should fire before its scheduled time"); +}); + +test("start: multiple new events in one fetch each trigger their own notifyOne", async () => { + const aborter = new AbortController(); + const ch = recordingChannel(); + const client = fakeClient( + [ + [ev("a")], // prime + [ev("a"), ev("b"), ev("c"), ev("d")], // b, c, d all new at once + ], + aborter, + ); + + const notifier = await newNotifier({ + cfg: cfg(), + aborter, + channels: [ch], + omClient: client, + now: () => zdt("2026-06-08T12:00:00"), + }); + + await notifier.start(); + + assert.deepEqual( + ch.ones.map((e) => e.slug), + ["b", "c", "d"], + "each newly-seen event should get its own notifyOne", + ); + assert.equal(ch.manys.length, 0); +}); + +test("start: an event that disappears then reappears is announced again as new", async () => { + // `seen` is rebuilt from the latest fetch each iteration (not accumulated), + // so a slug that drops out and comes back is treated as new again. This + // test locks that behavior in so a refactor doesn't silently change it. + const aborter = new AbortController(); + const ch = recordingChannel(); + const client = fakeClient( + [ + [ev("a"), ev("b")], // prime: a, b known + [ev("a")], // b disappears -> nothing new + [ev("a"), ev("b")], // b reappears -> announced again as new + ], + aborter, + ); + + const notifier = await newNotifier({ + cfg: cfg(), + aborter, + channels: [ch], + omClient: client, + now: () => zdt("2026-06-08T12:00:00"), + }); + + await notifier.start(); + + assert.deepEqual( + ch.ones.map((e) => e.slug), + ["b"], + "a reappearing slug should be announced once, as a new event", + ); +}); + +test("start: new-event notifications and the weekly digest both fire in the same iteration", async () => { + const aborter = new AbortController(); + const ch = recordingChannel(); + + // Clock starts Monday 06:00 (before the 07:30 digest), advances to 08:00 on + // the second iteration so the digest fires in the same pass that sees a new + // event. + let t = zdt("2026-06-08T06:00:00"); + const now = () => t; + + const prime = [ev("known", "2026-06-10T18:00:00")]; + const withNew = [ + ev("known", "2026-06-10T18:00:00"), + ev("fresh", "2026-06-11T18:00:00"), // new this iteration, and in the digest window + ]; + const client = fakeClient([prime, withNew], aborter, (i) => { + if (i === 1) t = zdt("2026-06-08T08:00:00"); // advance past the 07:30 digest + }); + + const notifier = await newNotifier({ + cfg: cfg({ weeklyNotifyDay: 1, weeklyNotifyHour: 7, weeklyNotifyMinute: 30 }), + aborter, + channels: [ch], + omClient: client, + now, + }); + + await notifier.start(); + + assert.deepEqual( + ch.ones.map((e) => e.slug), + ["fresh"], + "the new event should still trigger a notifyOne even as the digest fires", + ); + assert.equal(ch.manys.length, 1, "the weekly digest should also fire this iteration"); + assert.deepEqual( + ch.manys[0]!.map((e) => e.slug), + ["known", "fresh"], + "digest should include both in-window events regardless of newness", + ); +}); + +test("start: a failing channel does not block other channels from being notified", async (t) => { + t.mock.method(console, "error", () => {}); // silence the logged channel failure + const aborter = new AbortController(); + const good = recordingChannel("good"); + const bad: Channel = { + name: "bad", + notifyOne: () => Promise.reject(new Error("boom")), + notifyMany: () => Promise.reject(new Error("boom")), + }; + const client = fakeClient([[ev("a")], [ev("a"), ev("b")]], aborter); + + const notifier = await newNotifier({ + cfg: cfg(), + aborter, + channels: [bad, good], + omClient: client, + now: () => zdt("2026-06-08T12:00:00"), + }); + + await notifier.start(); + + assert.deepEqual( + good.ones.map((e) => e.slug), + ["b"], + "good channel should still receive the new event despite bad failing", + ); +}); + +test("start: weekly digest fires at the scheduled time and only includes events in the next 7 days", async () => { + const aborter = new AbortController(); + const ch = recordingChannel(); + + // Clock starts Monday 06:00 (before the 07:30 digest time), then advances to + // 08:00 on the second iteration so the digest fires. + let t = zdt("2026-06-08T06:00:00"); + const now = () => t; + + const events = [ + ev("past", "2026-06-07T18:00:00"), // before now -> excluded + ev("today", "2026-06-08T18:00:00"), // in window -> included + ev("midweek", "2026-06-10T18:00:00"), // in window -> included + ev("lateweek", "2026-06-14T18:00:00"), // in window -> included + ev("boundary", "2026-06-15T08:00:00"), // == now+7d, excluded (strict <) + ev("beyond", "2026-06-20T18:00:00"), // outside window -> excluded + ]; + + const client = fakeClient([events, events], aborter, (i) => { + if (i === 1) t = zdt("2026-06-08T08:00:00"); // advance past the 07:30 digest + }); + + const notifier = await newNotifier({ + cfg: cfg({ weeklyNotifyDay: 1, weeklyNotifyHour: 7, weeklyNotifyMinute: 30 }), + aborter, + channels: [ch], + omClient: client, + now, + }); + + await notifier.start(); + + assert.equal(ch.manys.length, 1, "exactly one weekly digest should be sent"); + assert.deepEqual( + ch.manys[0]!.map((e) => e.slug), + ["today", "midweek", "lateweek"], + "digest should include only events in [now, now+7d)", + ); + assert.equal(ch.ones.length, 0, "no new-event notifications when the event set is unchanged"); +}); diff --git a/src/notifier.ts b/src/notifier.ts index c1cb167..4020f3d 100644 --- a/src/notifier.ts +++ b/src/notifier.ts @@ -1,7 +1,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import type { Channel } from "./channels/channel.ts"; import type { Config } from "./config.ts"; -import { getOpenMeetClient } from "./openmeet.ts"; +import { getOpenMeetClient, type OpenMeetClient } from "./openmeet.ts"; import { nextWeeklyOccurrence, TIMEZONE } from "./schedule.ts"; interface Notifier { @@ -14,15 +14,24 @@ interface NotifierOpts { channels: Channel[]; aborter: AbortController; cfg: Config; + omClient?: OpenMeetClient; + now?: () => Temporal.ZonedDateTime; } -export async function newNotifier({ cfg, aborter, channels }: NotifierOpts): Promise { - const om = await getOpenMeetClient(cfg); +export async function newNotifier({ + cfg, + aborter, + channels, + omClient, + now: nowOpt, +}: NotifierOpts): Promise { + const om = omClient ?? (await getOpenMeetClient(cfg)); + const clock = nowOpt ?? (() => Temporal.Now.zonedDateTimeISO(TIMEZONE)); return { // Runs a loop that checks for both single-notify events and weekly digests async start() { - const now = () => Temporal.Now.zonedDateTimeISO(TIMEZONE); + const now = clock; const nextDigestAfter = (after: Temporal.ZonedDateTime) => nextWeeklyOccurrence(cfg, after); // Schedule the first digest strictly after startup so an occurrence that diff --git a/src/openmeet.ts b/src/openmeet.ts index 0004168..055af09 100644 --- a/src/openmeet.ts +++ b/src/openmeet.ts @@ -64,7 +64,7 @@ async function refreshOrReauth( } } -interface OpenMeetClient { +export interface OpenMeetClient { getUpcomingEvents( cfg: OpenMeetConfig & AtprotoLogin, today?: Temporal.PlainDate, diff --git a/src/schedule.test.ts b/src/schedule.test.ts index 9c1f5da..f6634b8 100644 --- a/src/schedule.test.ts +++ b/src/schedule.test.ts @@ -4,7 +4,7 @@ import test, { describe } from "node:test"; import { nextWeeklyOccurrence, TIMEZONE } from "./schedule.ts"; const MONDAY = 1; -const zdt = (s: string) => Temporal.ZonedDateTime.from(`${s}[`${TIMEZONE}`]`); +const zdt = (s: string) => Temporal.ZonedDateTime.from(`${s}[${TIMEZONE}]`); // 2026-06-08 is a Monday; 2026-06-10 is a Wednesday.