/** * reminderScheduler tests. * * Asserts that the reminder scan: * - Selects ONLY shared (isShared=true) AND timed (allDay=false) events * in the catch-up window (now, now+16min] (D-05/D-07) * - Does NOT dispatch reminders for all-day events (D-07) * - Does NOT dispatch reminders for non-shared (personal) events (D-05) * - Does NOT dispatch for an event whose dtstart <= now (already started) * - Fires EXACTLY ONCE across consecutive ticks while the event sits in the window * (per-uid exactly-once dedup; cross-tick double-fire prevention) * - Recovers from a missed/late cron tick: fires at a later scan when event is * still in (now, now+16min] even if the ideal 15-min tick was skipped * - WR-01: sentReminders.set(uid) is called AFTER dispatch (at-least-once delivery) * - CR-01: sentReminders Map is pruned of started events after each scan * - D-16: empty push_subscriptions -> zero sends / no crash * - T-05-19: per-subscription error isolation * * NEW (Plan 11-02): * - NOTIF-04: fires at T-reminderLeadMinutes (variable per-event lead), not fixed 16-min window * - NOTIF-05: NULL lead → no push; timed 0-lead → no push; personal events DO dispatch * - NOTIF-06: uid:dtstartMs compound dedup; rescheduled dtstart re-fires; all-day 9 AM branch * - D-09: humanized push body (humanizeLeadMinutes) * * Run: pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock the DB so we can control what events are returned vi.mock('../../src/db/client.js', () => ({ db: { select: vi.fn(), }, })); // Mock pushDispatcher so no real push occurs vi.mock('../../src/lib/pushDispatcher.js', () => ({ dispatchPush: vi.fn().mockResolvedValue(undefined), })); // ── Helpers ─────────────────────────────────────────────────────────────────── function makeSelectMock(rows: unknown[]) { return { from: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(rows), }), }), }), } as never; } function makeEventRow(overrides: { uid?: string; title?: string; dtstartUtc?: Date | null; dtstartDate?: string | null; allDay?: boolean; isShared?: boolean; reminderLeadMinutes?: number | null; subId?: number | null; subUserId?: number | null; subEndpoint?: string; subP256dh?: string; subAuth?: string; }) { return { uid: overrides.uid ?? 'test-uid-1', title: overrides.title ?? 'Test Event', dtstartUtc: overrides.dtstartUtc !== undefined ? overrides.dtstartUtc : null, dtstartDate: overrides.dtstartDate !== undefined ? overrides.dtstartDate : null, allDay: overrides.allDay ?? false, isShared: overrides.isShared ?? true, reminderLeadMinutes: overrides.reminderLeadMinutes !== undefined ? overrides.reminderLeadMinutes : 15, subId: overrides.subId ?? 1, subUserId: overrides.subUserId ?? 1, subEndpoint: overrides.subEndpoint ?? 'https://push.example.com/1', subP256dh: overrides.subP256dh ?? 'p256dh-key', subAuth: overrides.subAuth ?? 'auth-secret', }; } // ── Filtering tests (NOTIF-04/05: variable lead, NULL-vs-0, personal calendar) ──────── describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOTIF-04/05)', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it('NOTIF-04: dispatches a timed event when now is inside the lead-driven fire window (30-min lead)', async () => { // Fire time = dtstartUtc - 30min. now = dtstartUtc - 30min (exactly at fire time). // The event MUST dispatch. It would NOT dispatch under the old fixed 16-min window. const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const dtstartUtc = new Date('2026-06-15T10:30:00Z'); // 30 min from now const row = makeEventRow({ uid: 'notif04-30min-uid', dtstartUtc, reminderLeadMinutes: 30, isShared: true, }); vi.mocked(db.select).mockReturnValue(makeSelectMock([row])); await runReminderCheck(now); expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); }); it('NOTIF-04: does NOT dispatch a 30-min lead event when now is 5 min before start (fire time already passed)', async () => { // Fire time = dtstartUtc - 30min = 09:30. now = 10:25 (event in 5 min, past fire time). // SQL should not return this row (fire time check). Simulate with empty mock. const now = new Date('2026-06-15T10:25:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); // Simulate: event would have been in old 16-min window but already past its fire time vi.mocked(db.select).mockReturnValue(makeSelectMock([])); await runReminderCheck(now); expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); it('NOTIF-05: a personal (isShared=false) timed event with non-null lead DOES dispatch (restriction dropped)', async () => { const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const dtstartUtc = new Date('2026-06-15T10:15:00Z'); const row = makeEventRow({ uid: 'personal-event-uid', dtstartUtc, reminderLeadMinutes: 15, isShared: false, // personal calendar — must still dispatch }); vi.mocked(db.select).mockReturnValue(makeSelectMock([row])); await runReminderCheck(now); // Personal event must dispatch (isShared restriction dropped per NOTIF-05) expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); }); it('NOTIF-05: an event with reminderLeadMinutes=NULL produces zero dispatches', async () => { const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); // NULL lead events are excluded by SQL WHERE (reminder_lead_minutes IS NOT NULL) vi.mocked(db.select).mockReturnValue(makeSelectMock([])); await runReminderCheck(now); expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); it('NOTIF-05: a timed event with reminderLeadMinutes=0 produces zero dispatches (D-06: 0 on timed = None)', async () => { const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const dtstartUtc = new Date('2026-06-15T10:01:00Z'); const row = makeEventRow({ uid: 'timed-zero-uid', dtstartUtc, reminderLeadMinutes: 0, // D-06: 0 on timed = None (skip) allDay: false, }); // Even if the query returned a timed-0 event, JS must skip it vi.mocked(db.select).mockReturnValue(makeSelectMock([row])); await runReminderCheck(now); expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); it('does not dispatch for an event whose start has already passed (dtstart <= now)', async () => { const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); // gt(dtstartUtc, now) excludes already-started events; simulate by returning empty rows vi.mocked(db.select).mockReturnValue(makeSelectMock([])); await runReminderCheck(now); expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); }); // ── D-16: empty subscriptions — zero sends / no crash ──────────────────────── describe('reminderScheduler — D-16: empty push_subscriptions', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it('produces zero sends and does not crash when push_subscriptions is empty (D-16)', async () => { const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); // Cross-join with empty push_subscriptions returns no rows vi.mocked(db.select).mockReturnValue(makeSelectMock([])); await expect(runReminderCheck(now)).resolves.toBeUndefined(); expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); }); // ── Catch-up / missed-tick recovery ────────────────────────────────────────── describe('reminderScheduler — catch-up window and missed-tick recovery', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it('SINGLE-FIRE (uid:dtstartMs): dispatches exactly once across three consecutive ticks while event is in window', async () => { // NOTIF-06: dedup key is uid:dtstartMs — same compound key fires once across 3 ticks const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const t0 = new Date('2026-06-15T10:00:00Z'); // Event is 15 min out from t0. Fire window (now-60s, now] catches it at t0. const eventDtstart = new Date('2026-06-15T10:15:00Z'); const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a', }; function rowForNow() { return makeEventRow({ uid: 'single-fire-uid', dtstartUtc: eventDtstart, reminderLeadMinutes: 15, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, subP256dh: sub.p256dh, subAuth: sub.auth, }); } // Tick at t0 — fire time is exactly now (dtstartUtc - 15min = t0); dispatches once vi.setSystemTime(t0); vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()])); await runReminderCheck(t0); // Tick at t0+1min — same uid:dtstartMs already in sentReminders; must NOT re-dispatch const t1 = new Date(t0.getTime() + 60 * 1000); vi.setSystemTime(t1); vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()])); await runReminderCheck(t1); // Tick at t0+2min — still deduped const t2 = new Date(t0.getTime() + 2 * 60 * 1000); vi.setSystemTime(t2); vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()])); await runReminderCheck(t2); // Exactly one dispatch total: uid:dtstartMs dedup prevents re-fire on ticks 2 and 3 expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); }); it('RESCHEDULE (uid:dtstartMs): a rescheduled event (same uid, new dtstart) fires again', async () => { // NOTIF-06: compound key uid:dtstartMs — new dtstartMs means new key → re-fires const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const originalDtstart = new Date('2026-06-15T10:15:00Z'); const row = makeEventRow({ uid: 'reschedule-uid', dtstartUtc: originalDtstart, reminderLeadMinutes: 15, }); // First tick — fires vi.mocked(db.select).mockReturnValue(makeSelectMock([row])); await runReminderCheck(now); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // Advance time so original dtstart is pruned; event rescheduled to a new dtstart const futureNow = new Date('2026-06-15T10:20:00Z'); // past original dtstart vi.setSystemTime(futureNow); vi.mocked(db.select).mockReturnValue(makeSelectMock([])); // empty (original started) await runReminderCheck(futureNow); // prune old entry // Now event has new dtstart (same uid, different dtstartMs) — must fire again const rescheduledNow = new Date('2026-06-15T11:00:00Z'); const rescheduledDtstart = new Date('2026-06-15T11:15:00Z'); vi.setSystemTime(rescheduledNow); const rescheduledRow = makeEventRow({ uid: 'reschedule-uid', dtstartUtc: rescheduledDtstart, reminderLeadMinutes: 15, }); vi.mocked(db.select).mockReturnValue(makeSelectMock([rescheduledRow])); await runReminderCheck(rescheduledNow); // Must fire again — new compound key uid:rescheduledDtstartMs expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2); }); it('MISSED-TICK-RECOVERY: fires when scan runs inside the 60s catch-up window after ideal tick was skipped', async () => { const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); // Event at 10:15:00Z, 15-min lead → fire time 10:00:00Z. // Ideal scan at 10:00Z was missed. Call at 10:00:45Z — still in (fire-60s, fire] window. const eventDtstart = new Date('2026-06-15T10:15:00Z'); const recoveryNow = new Date('2026-06-15T10:00:45Z'); // 45s after ideal fire time vi.setSystemTime(recoveryNow); const row = makeEventRow({ uid: 'missed-tick-uid', dtstartUtc: eventDtstart, reminderLeadMinutes: 15, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/missed', subP256dh: 'k', subAuth: 'a', }); vi.mocked(db.select).mockReturnValue(makeSelectMock([row])); await runReminderCheck(recoveryNow); // Reminder must fire even though the ideal-mark tick was skipped expect(vi.mocked(dispatchPush).mock.calls.length).toBeGreaterThanOrEqual(1); }); it('dispatches to all subscribers for a single event (fan-out)', async () => { const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const dtstartUtc = new Date('2026-06-15T10:15:00Z'); const rows = [ makeEventRow({ uid: 'fanout-uid', dtstartUtc, reminderLeadMinutes: 15, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', subP256dh: 'k1', subAuth: 'a1', }), makeEventRow({ uid: 'fanout-uid', dtstartUtc, reminderLeadMinutes: 15, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', subP256dh: 'k2', subAuth: 'a2', }), ]; vi.mocked(db.select).mockReturnValue(makeSelectMock(rows)); await runReminderCheck(now); // One dispatch per subscriber expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2); }); }); // ── WR-01: mark-sent after dispatch ────────────────────────────────────────── describe('reminderScheduler — WR-01: mark-sent after dispatch', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it('does not re-dispatch the same uid after a successful dispatch in the same module instance', async () => { // WR-01: sentReminders.set(uid) is called AFTER the fan-out loop completes. // After a successful dispatch the uid is recorded; a second run with the same // module instance must not re-fire for the same uid. const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a', }; const eventRow = makeEventRow({ uid: 'wr01-dedup-uid', title: 'WR-01 dedup event', dtstartUtc: new Date('2026-06-15T10:15:00Z'), reminderLeadMinutes: 15, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, subP256dh: sub.p256dh, subAuth: sub.auth, }); vi.mocked(dispatchPush).mockResolvedValue(undefined); // First run — dispatch succeeds; uid is recorded after the loop vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow])); await runReminderCheck(now); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // dispatched once // Second run with same now — uid is in sentReminders; should NOT re-dispatch vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow])); await runReminderCheck(now); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 — deduped }); }); // ── CR-01: started-event pruning ───────────────────────────────────────────── describe('reminderScheduler — CR-01: sentReminders Map pruning', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it('prunes started-event entries so a different future event with the same uid can fire again', async () => { const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a', }; const eventDtstart = new Date('2026-06-15T10:15:00Z'); const uid = 'prune-test-uid'; const eventRow = makeEventRow({ uid, title: 'Prune test event', dtstartUtc: eventDtstart, reminderLeadMinutes: 15, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, subP256dh: sub.p256dh, subAuth: sub.auth, }); // Tick at t0 (now=10:00): event 15 min out — fires const t0 = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(t0); vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow])); await runReminderCheck(t0); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // fired // Same t0 — uid still in Map — must NOT re-fire vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow])); await runReminderCheck(t0); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 // Advance past dtstart (now=10:20): event has started; CR-01 prunes the uid entry. // The SQL WHERE gt(dtstartUtc, now) would return no rows, so simulate empty. const tPast = new Date('2026-06-15T10:20:00Z'); vi.setSystemTime(tPast); vi.mocked(db.select).mockReturnValue(makeSelectMock([])); await runReminderCheck(tPast); // Pruning fires; dispatch count stays at 1 expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); }); }); // ── T-05-19: per-subscription error isolation ──────────────────────────────── describe('reminderScheduler — T-05-19: per-subscription error isolation', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it('continues dispatching to remaining subscribers when one subscription throws', async () => { const now = new Date('2026-06-15T10:00:00Z'); vi.setSystemTime(now); const { db } = await import('../../src/db/client.js'); const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); const dtstartUtc = new Date('2026-06-15T10:15:00Z'); const rows = [ makeEventRow({ uid: 'iso-uid', dtstartUtc, reminderLeadMinutes: 15, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', subP256dh: 'k1', subAuth: 'a1', }), makeEventRow({ uid: 'iso-uid', dtstartUtc, reminderLeadMinutes: 15, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', subP256dh: 'k2', subAuth: 'a2', }), ]; vi.mocked(db.select).mockReturnValue(makeSelectMock(rows)); // First subscriber throws; second should still be attempted vi.mocked(dispatchPush) .mockRejectedValueOnce(new Error('network error')) .mockResolvedValueOnce(undefined); await expect(runReminderCheck(now)).resolves.toBeUndefined(); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2); // both attempted }); });