/** * 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 ─────────────────────────────────────────────────────────────────── /** * Make a select mock chain that resolves `.where()` with the given rows. * Supports both one-join and two-join chains (from().innerJoin[.innerJoin]().where()). */ function makeSelectMock(rows: unknown[]) { const whereResolve = vi.fn().mockResolvedValue(rows); const innerJoinLevel2 = { where: whereResolve, innerJoin: vi.fn().mockReturnValue({ where: whereResolve }), }; return { from: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue(innerJoinLevel2), }), } as never; } /** * Setup the db.select mock so the FIRST call (timed query) returns `timedRows` * and the SECOND call (all-day query) returns `allDayRows` (default empty). * * runReminderCheck() issues two sequential db.select() calls: * 1st: timed events query * 2nd: all-day events query */ function mockTwoQueries( db: { select: ReturnType }, timedRows: unknown[], allDayRows: unknown[] = [], ) { db.select .mockReturnValueOnce(makeSelectMock(timedRows)) .mockReturnValueOnce(makeSelectMock(allDayRows)); } 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, }); mockTwoQueries(vi.mocked(db), [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 mockTwoQueries(vi.mocked(db), []); 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 }); mockTwoQueries(vi.mocked(db), [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) mockTwoQueries(vi.mocked(db), []); 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 mockTwoQueries(vi.mocked(db), [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 mockTwoQueries(vi.mocked(db), []); 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 for both queries mockTwoQueries(vi.mocked(db), []); 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); mockTwoQueries(vi.mocked(db), [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); mockTwoQueries(vi.mocked(db), [rowForNow()]); await runReminderCheck(t1); // Tick at t0+2min — still deduped const t2 = new Date(t0.getTime() + 2 * 60 * 1000); vi.setSystemTime(t2); mockTwoQueries(vi.mocked(db), [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 mockTwoQueries(vi.mocked(db), [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); mockTwoQueries(vi.mocked(db), []); // 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, }); mockTwoQueries(vi.mocked(db), [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', }); mockTwoQueries(vi.mocked(db), [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', }), ]; mockTwoQueries(vi.mocked(db), 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:dtstartMs is recorded after the loop mockTwoQueries(vi.mocked(db), [eventRow]); await runReminderCheck(now); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // dispatched once // Second run with same now — uid:dtstartMs is in sentReminders; should NOT re-dispatch mockTwoQueries(vi.mocked(db), [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); mockTwoQueries(vi.mocked(db), [eventRow]); await runReminderCheck(t0); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // fired // Same t0 — uid:dtstartMs still in Map — must NOT re-fire mockTwoQueries(vi.mocked(db), [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:dtstartMs 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); mockTwoQueries(vi.mocked(db), []); await runReminderCheck(tPast); // Pruning fires; dispatch count stays at 1 expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); }); }); // ── D-09: humanizeLeadMinutes body formatter ───────────────────────────────── describe('humanizeLeadMinutes — D-09 humanized push body formatter', () => { it('maps 30 min → "Starts in 30 min"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(30)).toBe('Starts in 30 min'); }); it('maps 59 min → "Starts in 59 min" (< 60 bucket)', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(59)).toBe('Starts in 59 min'); }); it('maps 60 min → "Starts in 1 hour"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(60)).toBe('Starts in 1 hour'); }); it('maps 90 min → "Starts in 1 hour" (60–119 bucket, not 2 hours)', async () => { // Math.round(90/60)=2 would give "2 hours" — branch ordering must prevent this const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(90)).toBe('Starts in 1 hour'); }); it('maps 120 min → "Starts in 2 hours"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(120)).toBe('Starts in 2 hours'); }); it('maps 1440 min → "Starts in 1 day"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(1440)).toBe('Starts in 1 day'); }); it('maps 2880 min → "Starts in 2 days"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(2880)).toBe('Starts in 2 days'); }); it('maps 10080 min → "Starts in 7 days"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(10080)).toBe('Starts in 7 days'); }); }); describe('reminderScheduler — D-09: humanized push body in dispatch', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it('dispatched notification body is humanized from configured lead (not live minutes-to-start)', async () => { // D-09: body driven by event.reminderLeadMinutes (DB ground truth), not by (dtstart - now) // A 1440-min (1 day) lead event should produce "Starts in 1 day", not "Starts in 30 min" 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'); // Event exactly 1440 min (1 day) from now → fire time = dtstartUtc - 1440min = now const dtstartUtc = new Date('2026-06-16T10:00:00Z'); // tomorrow 10:00Z const row = makeEventRow({ uid: 'humanize-body-uid', title: 'Meeting Tomorrow', dtstartUtc, reminderLeadMinutes: 1440, }); mockTwoQueries(vi.mocked(db), [row]); await runReminderCheck(now); expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); const [, payload] = vi.mocked(dispatchPush).mock.calls[0]!; // Body must be "Starts in 1 day" (configured lead), not "Starts in 1470 min" (live delta) expect((payload as { body: string }).body).toBe('Starts in 1 day'); }); }); // ── NOTIF-06: All-day 9 AM-local fire branch ───────────────────────────────── describe('reminderScheduler — NOTIF-06: all-day 9 AM-local fire branch', () => { // All-day tests use the server timezone (America/New_York = UTC-4 in summer). // computeAlertInstantUtc('2026-06-15', 0, 'America/New_York') = 2026-06-15T13:00:00Z. // Tests set `now` to the expected alert UTC to trigger the fire window. // Pin TZ explicitly so these assertions are deterministic regardless of the host/CI // timezone (CI runs UTC; the scheduler reads process.env.TZ at call time, D-04). let prevTz: string | undefined; beforeEach(() => { prevTz = process.env.TZ; process.env.TZ = 'America/New_York'; vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); if (prevTz === undefined) delete process.env.TZ; else process.env.TZ = prevTz; }); it('all-day 0-lead fires at 9 AM local (not midnight) on event date', async () => { // 2026-06-15 all-day, 0-lead → alert = computeAlertInstantUtc('2026-06-15', 0, tz) // America/New_York summer (EDT, UTC-4) → 9 AM EDT = 2026-06-15T13:00:00Z const alertUtc = new Date('2026-06-15T13:00:00Z'); vi.setSystemTime(alertUtc); // now = alert time → fire window triggers 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 allDayRow = makeEventRow({ uid: 'allday-0lead-uid', title: 'All-Day Event', dtstartDate: '2026-06-15', dtstartUtc: null, allDay: true, reminderLeadMinutes: 0, }); // Second query (all-day) returns this row; timed query empty mockTwoQueries(vi.mocked(db), [], [allDayRow]); await runReminderCheck(alertUtc); // Must dispatch at 13:00 UTC (9 AM EDT) — NOT at midnight expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); }); it('all-day 0-lead does NOT fire at midnight (2026-06-15T00:00:00Z)', async () => { // Midnight UTC is NOT 9 AM local → must not dispatch const midnight = new Date('2026-06-15T00:00:00Z'); vi.setSystemTime(midnight); 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 allDayRow = makeEventRow({ uid: 'allday-midnight-uid', dtstartDate: '2026-06-15', dtstartUtc: null, allDay: true, reminderLeadMinutes: 0, }); mockTwoQueries(vi.mocked(db), [], [allDayRow]); await runReminderCheck(midnight); // Alert time is 13:00 UTC, not midnight → no dispatch expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); it('all-day 1440-lead fires at 9 AM local the day before (2026-06-14T13:00:00Z)', async () => { // 2026-06-15 all-day, 1440-lead → alert day = 2026-06-14 // 9 AM EDT on 2026-06-14 = 2026-06-14T13:00:00Z const alertUtc = new Date('2026-06-14T13:00:00Z'); vi.setSystemTime(alertUtc); 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 allDayRow = makeEventRow({ uid: 'allday-1440lead-uid', dtstartDate: '2026-06-15', dtstartUtc: null, allDay: true, reminderLeadMinutes: 1440, }); mockTwoQueries(vi.mocked(db), [], [allDayRow]); await runReminderCheck(alertUtc); expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); }); it('all-day 10080-lead fires at 9 AM local 7 days before the event', async () => { // 2026-06-22 all-day, 10080-lead (7 days) → alert day = 2026-06-15 // 9 AM EDT on 2026-06-15 = 2026-06-15T13:00:00Z const alertUtc = new Date('2026-06-15T13:00:00Z'); vi.setSystemTime(alertUtc); 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 allDayRow = makeEventRow({ uid: 'allday-10080lead-uid', dtstartDate: '2026-06-22', dtstartUtc: null, allDay: true, reminderLeadMinutes: 10080, }); mockTwoQueries(vi.mocked(db), [], [allDayRow]); await runReminderCheck(alertUtc); expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); }); it('all-day dedup: same uid:dtstartMs fires exactly once across consecutive ticks', async () => { // NOTIF-06: uid:dtstartDate-midnight-ms dedup for all-day events const alertUtc = new Date('2026-06-15T13:00:00Z'); // fire time for '2026-06-15', 0-lead, EDT 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 allDayRow = makeEventRow({ uid: 'allday-dedup-uid', dtstartDate: '2026-06-15', dtstartUtc: null, allDay: true, reminderLeadMinutes: 0, }); // Tick 1: at alert time — fires vi.setSystemTime(alertUtc); mockTwoQueries(vi.mocked(db), [], [allDayRow]); await runReminderCheck(alertUtc); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // Tick 2: 30s later — same uid:dtstartMs in sentReminders → no re-dispatch const tick2 = new Date(alertUtc.getTime() + 30 * 1000); vi.setSystemTime(tick2); mockTwoQueries(vi.mocked(db), [], [allDayRow]); await runReminderCheck(tick2); expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 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', }), ]; mockTwoQueries(vi.mocked(db), 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 }); }); // ── CR-02: all-day-aware push body (Phase 11 Plan 05) ──────────────────────── describe('humanizeLeadMinutes — CR-02 all-day-aware push body', () => { // CR-02: humanizeLeadMinutes(0) produces "Starts in 0 min" for timed events, // but for all-day same-day events (lead=0) the correct body is "Today". // The function must accept an isAllDay flag to return sensible all-day wording. // These tests MUST FAIL before the fix — humanizeLeadMinutes takes only one arg. it('CR-02: all-day same-day (lead=0) body is NOT "Starts in 0 min" (isAllDay=true)', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); // Before fix: humanizeLeadMinutes(0) → 'Starts in 0 min' (wrong for all-day same-day) // After fix: humanizeLeadMinutes(0, true) → 'Today' expect(humanizeLeadMinutes(0, true)).not.toBe('Starts in 0 min'); }); it('CR-02: all-day same-day (lead=0, isAllDay=true) body is "Today"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(0, true)).toBe('Today'); }); it('CR-02: all-day 1-day lead (1440 min, isAllDay=true) body is "Tomorrow"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(1440, true)).toBe('Tomorrow'); }); it('CR-02: all-day 2-day lead (2880 min, isAllDay=true) body is "In 2 days"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(2880, true)).toBe('In 2 days'); }); it('CR-02: all-day 1-week lead (10080 min, isAllDay=true) body is "In 1 week"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); expect(humanizeLeadMinutes(10080, true)).toBe('In 1 week'); }); it('CR-02: timed event body unchanged — humanizeLeadMinutes(0, false) is "Starts in 0 min"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); // Timed event 0-lead: treated as None by the scheduler, but the formatter // should still return the existing "Starts in 0 min" for completeness expect(humanizeLeadMinutes(0, false)).toBe('Starts in 0 min'); }); it('CR-02: timed event body unchanged — humanizeLeadMinutes(30) still "Starts in 30 min"', async () => { const { humanizeLeadMinutes } = await import('../../src/broker/reminderScheduler.js'); // Default isAllDay=false: existing timed behavior must be unchanged expect(humanizeLeadMinutes(30)).toBe('Starts in 30 min'); }); }); // ── Plan 18-03: stored household_timezone drives all-day 9 AM-local fire ───── // // D-05: when getHouseholdTimezone(db) returns a stored IANA zone, the scheduler // must compute the 9 AM alert using THAT zone (not process.env.TZ). // D-06: the existing process.env.TZ-pinned tests above still pass unchanged // (the fallback fires when no app_config row is returned). // // Before the rewire (GREEN), serverTz is still read from process.env.TZ, so the // stored 'America/Chicago' has no effect → these tests FAIL (RED gate). /** * Mock a db with three sequential select calls: * 1st: timed events query → timedRows * 2nd: all-day events query → allDayRows * 3rd: getHouseholdTimezone app_config query → [{value: tz}] (or [] for no-row) * * The 3rd select chain is: select({value}).from(appConfig).where(...).limit(1) * which resolves via: from → where → limit → promise. */ function makeAppConfigSelectMock(tzRow: { value: string } | null) { const rows = tzRow ? [tzRow] : []; const limitResolve = vi.fn().mockResolvedValue(rows); // .where() must return an object with a .limit() method (not the fn itself) const whereFn = vi.fn().mockReturnValue({ limit: limitResolve }); return { from: vi.fn().mockReturnValue({ where: whereFn }), } as never; } function mockThreeQueries( db: { select: ReturnType }, timedRows: unknown[], allDayRows: unknown[], tzRow: { value: string } | null, ) { // Reset any unconsumed mockReturnValueOnce entries from a prior test before adding new ones. // vi.clearAllMocks() does NOT flush the once-queue; vi.resetModules() may return the same // vi.fn() instance across tests (Vitest caches mock factories across module resets). db.select.mockReset(); db.select .mockReturnValueOnce(makeSelectMock(timedRows)) .mockReturnValueOnce(makeSelectMock(allDayRows)) .mockReturnValueOnce(makeAppConfigSelectMock(tzRow)); } describe('reminderScheduler — Plan 18-03: stored household_timezone drives all-day alert (D-05)', () => { // These tests FAIL before the rewire because serverTz is still read from process.env.TZ. // After the rewire, getHouseholdTimezone(db) returns the stored zone → GREEN. // // Event date: 2026-06-20 (summer). // America/New_York: EDT (UTC-4) → 9 AM = 13:00 UTC. // America/Chicago: CDT (UTC-5) → 9 AM = 14:00 UTC. // process.env.TZ is set to 'America/New_York' for these tests; stored zone is 'America/Chicago'. // When the stored zone drives the computation: alert fires at 14:00 UTC. let prevTz: string | undefined; beforeEach(() => { prevTz = process.env.TZ; process.env.TZ = 'America/New_York'; vi.useFakeTimers(); vi.resetModules(); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); if (prevTz === undefined) delete process.env.TZ; else process.env.TZ = prevTz; }); it('D-05: stored America/Chicago fires at 9 AM Chicago (14:00 UTC), not 13:00 UTC (New York)', async () => { // 2026-06-20 all-day, 0-lead, stored zone 'America/Chicago' (CDT, UTC-5) // Expected alert: 9 AM CDT = 2026-06-20T14:00:00Z const alertUtcChicago = new Date('2026-06-20T14:00:00Z'); vi.setSystemTime(alertUtcChicago); // now = Chicago alert time 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 allDayRow = makeEventRow({ uid: 'stored-tz-chicago-uid', title: 'All-Day Stored TZ Event', dtstartDate: '2026-06-20', dtstartUtc: null, allDay: true, reminderLeadMinutes: 0, }); // Third select returns the stored 'America/Chicago' row from app_config mockThreeQueries(vi.mocked(db), [], [allDayRow], { value: 'America/Chicago' }); await runReminderCheck(alertUtcChicago); // Must dispatch at 14:00 UTC (9 AM CDT) — the stored zone drives the computation expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce(); }); it('D-05: stored America/Chicago does NOT fire at 13:00 UTC (that is 9 AM New York)', async () => { // now = 13:00 UTC. If stored zone is 'America/Chicago', alert = 14:00 UTC → NOT yet. const nyAlertTime = new Date('2026-06-20T13:00:00Z'); vi.setSystemTime(nyAlertTime); 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 allDayRow = makeEventRow({ uid: 'stored-tz-no-early-fire-uid', title: 'All-Day No Early Fire', dtstartDate: '2026-06-20', dtstartUtc: null, allDay: true, reminderLeadMinutes: 0, }); mockThreeQueries(vi.mocked(db), [], [allDayRow], { value: 'America/Chicago' }); await runReminderCheck(nyAlertTime); // Stored zone is Chicago → alert is 14:00 UTC, not 13:00 UTC → must NOT fire expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled(); }); });