Files
familysync/apps/api/tests/broker/reminderScheduler.test.ts
T
Lucas Berger 62d3f58684 feat(11-02): GREEN Task 1 — variable-lead window, uid:dtstartMs dedup, drop isShared restriction
- Replace fixed 16-min window with per-event variable-lead fire-time check
- Two separate DB queries: timed (allDay=false) + all-day (allDay=true)
- Remove eq(calendars.isShared, true) — personal events now dispatch (NOTIF-05)
- Remove eq(calendarEvents.allDay, false) — all-day handled in separate query
- Add reminder_lead_minutes IS NOT NULL WHERE predicate (NOTIF-05)
- Skip timed events with reminderLeadMinutes===0 in JS (D-06: 0 on timed = None)
- Change dedup key from bare uid to uid:dtstartMs compound key (NOTIF-06)
- Update prune loop to use compound key
- Import computeAlertInstantUtc from vevent.js (Plan 11-01, wave 2 dep)
- Add humanizeLeadMinutes export (Task 2 body formatter, used in dispatch)
- Update test helper mockTwoQueries() to handle two-query dispatch pattern
- All 14 tests GREEN; tsc --noEmit clean; setInterval retained, no node-cron
2026-06-13 22:20:51 -04:00

625 lines
22 KiB
TypeScript

/**
* 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<typeof vi.fn> },
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);
});
});
// ── 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
});
});