diff --git a/apps/api/tests/broker/reminderScheduler.test.ts b/apps/api/tests/broker/reminderScheduler.test.ts index 2a8896b..e143bf7 100644 --- a/apps/api/tests/broker/reminderScheduler.test.ts +++ b/apps/api/tests/broker/reminderScheduler.test.ts @@ -16,6 +16,12 @@ * - 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 */ @@ -50,7 +56,11 @@ function makeSelectMock(rows: unknown[]) { function makeEventRow(overrides: { uid?: string; title?: string; - dtstartUtc: Date; + dtstartUtc?: Date | null; + dtstartDate?: string | null; + allDay?: boolean; + isShared?: boolean; + reminderLeadMinutes?: number | null; subId?: number | null; subUserId?: number | null; subEndpoint?: string; @@ -60,9 +70,11 @@ function makeEventRow(overrides: { return { uid: overrides.uid ?? 'test-uid-1', title: overrides.title ?? 'Test Event', - dtstartUtc: overrides.dtstartUtc, - allDay: false, - isShared: true, + 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', @@ -71,9 +83,9 @@ function makeEventRow(overrides: { }; } -// ── Filtering tests (D-05 / D-07) ──────────────────────────────────────────── +// ── Filtering tests (NOTIF-04/05: variable lead, NULL-vs-0, personal calendar) ──────── -describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => { +describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOTIF-04/05)', () => { beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); @@ -84,7 +96,9 @@ describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => vi.clearAllMocks(); }); - it('does not dispatch reminders for all-day events (D-07)', async () => { + 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); @@ -92,15 +106,38 @@ describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); - // allDay=true events are excluded by the SQL WHERE; simulate by returning empty rows - vi.mocked(db.select).mockReturnValue(makeSelectMock([])); + 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, + }); - await runReminderCheck(); + 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('does not dispatch reminders for non-shared (personal) calendar events (D-05)', async () => { + 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); @@ -108,10 +145,55 @@ describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => const { dispatchPush } = await import('../../src/lib/pushDispatcher.js'); const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js'); - // isShared=false events are excluded by the SQL WHERE; simulate by returning empty rows - vi.mocked(db.select).mockReturnValue(makeSelectMock([])); + 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 + }); - await runReminderCheck(); + 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(); }); @@ -175,13 +257,14 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => vi.clearAllMocks(); }); - it('SINGLE-FIRE: dispatches exactly once across three consecutive ticks while event is in window', async () => { + 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; still in (now, now+16min] at t0+1min (14 min out) and t0+2min (13 min out) + // 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 = { @@ -196,7 +279,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => return makeEventRow({ uid: 'single-fire-uid', dtstartUtc: eventDtstart, - ...sub, + reminderLeadMinutes: 15, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, @@ -205,54 +288,92 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => }); } - // Tick at t0 (event 15 min out) + // 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 (event 14 min out — still in window, same uid) + // 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 (event 13 min out — still in window, same uid) + // 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 dedup prevents re-fire on ticks 2 and 3 + // 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('MISSED-TICK-RECOVERY: fires when scan runs 8 min before event after ideal tick was skipped', async () => { + 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'); - // Event at 10:15:00Z. The ideal 15-min scan (10:00:00Z) was missed. - // Call at 10:07:00Z — event is 8 min out, still > now and <= now+16min. + 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:07:00Z'); + const recoveryNow = new Date('2026-06-15T10:00:45Z'); // 45s after ideal fire time vi.setSystemTime(recoveryNow); - const sub = { - id: 1, - userId: 1, - endpoint: 'https://push.example.com/missed', - p256dh: 'k', - auth: 'a', - }; const row = makeEventRow({ uid: 'missed-tick-uid', dtstartUtc: eventDtstart, - subId: sub.id, - subUserId: sub.userId, - subEndpoint: sub.endpoint, - subP256dh: sub.p256dh, - subAuth: sub.auth, + reminderLeadMinutes: 15, + subId: 1, + subUserId: 1, + subEndpoint: 'https://push.example.com/missed', + subP256dh: 'k', + subAuth: 'a', }); vi.mocked(db.select).mockReturnValue(makeSelectMock([row])); @@ -276,6 +397,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => makeEventRow({ uid: 'fanout-uid', dtstartUtc, + reminderLeadMinutes: 15, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', @@ -285,6 +407,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () => makeEventRow({ uid: 'fanout-uid', dtstartUtc, + reminderLeadMinutes: 15, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', @@ -336,6 +459,7 @@ describe('reminderScheduler — WR-01: mark-sent after dispatch', () => { 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, @@ -389,6 +513,7 @@ describe('reminderScheduler — CR-01: sentReminders Map pruning', () => { uid, title: 'Prune test event', dtstartUtc: eventDtstart, + reminderLeadMinutes: 15, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, @@ -445,6 +570,7 @@ describe('reminderScheduler — T-05-19: per-subscription error isolation', () = makeEventRow({ uid: 'iso-uid', dtstartUtc, + reminderLeadMinutes: 15, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', @@ -454,6 +580,7 @@ describe('reminderScheduler — T-05-19: per-subscription error isolation', () = makeEventRow({ uid: 'iso-uid', dtstartUtc, + reminderLeadMinutes: 15, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2',