Files
familysync/apps/api/tests/broker/reminderScheduler.test.ts
T
Lucas Berger c80845cdba feat(18-03): route all-day reminder TZ through stored household_timezone
- reminderScheduler.ts: add import { getHouseholdTimezone } from '../lib/householdTimezone.js'
  and replace bare process.env.TZ ?? Intl... at line 247 with await getHouseholdTimezone(db)
- outboxWorker.ts: add same import and replace BOTH bare tz lookups at the update-branch
  (~line 501) and create-branch (~line 607) with await getHouseholdTimezone(db)
- D-05 satisfied: all three all-day sites now read from the single stored accessor
- D-06 satisfied: getHouseholdTimezone falls back to process.env.TZ → Intl when unset;
  existing process.env.TZ-pinned tests pass unchanged
- D-07 satisfied: eventDateTime.ts and hydrateEvents.ts are not modified
- outboxWorker.test.ts: update wireMockChain() to handle app_config table with where().limit()
  chain returning empty rows (D-06 fallback), so existing CAL-13 all-day test stays green
- reminderScheduler.test.ts: update mockTwoQueries to mock the new third db.select() call
  (getHouseholdTimezone) returning no row (D-06 fallback), keeping all 37 existing tests green
- All 76 broker tests pass; tsc --noEmit clean
2026-06-14 22:31:37 -04:00

1048 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`,
* the SECOND call (all-day query) returns `allDayRows` (default empty), and the
* THIRD call (getHouseholdTimezone app_config lookup, Plan 18-03) returns no row
* so the D-06 fallback fires (process.env.TZ → Intl).
*
* runReminderCheck() after the Plan 18-03 rewire issues three sequential db.select() calls:
* 1st: timed events query
* 2nd: all-day events query
* 3rd: getHouseholdTimezone → SELECT value FROM app_config WHERE key='household_timezone'
*
* Existing tests that pin process.env.TZ rely on the D-06 fallback (empty app_config row),
* so the third call returning no row keeps them green unchanged.
*
* To test a stored timezone (D-05), use mockThreeQueries instead.
*/
function mockTwoQueries(
db: { select: ReturnType<typeof vi.fn> },
timedRows: unknown[],
allDayRows: unknown[] = [],
) {
// Reset pending once-values to prevent cross-test queue contamination.
db.select.mockReset();
db.select
.mockReturnValueOnce(makeSelectMock(timedRows))
.mockReturnValueOnce(makeSelectMock(allDayRows))
.mockReturnValueOnce(makeAppConfigSelectMock(null)); // null → D-06 fallback (no stored TZ)
}
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" (60119 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<typeof vi.fn> },
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();
});
});