Files
familysync/apps/api/tests/broker/reminderScheduler.test.ts
T
Lucas Berger 1caa2e36d2 test(11-05): RED — CR-02 all-day-aware humanizeLeadMinutes
5 new tests asserting isAllDay=true branch: lead=0→"Today", 1440→"Tomorrow",
2880→"In 2 days", 10080→"In 1 week"; timed (isAllDay=false) behavior unchanged.
All 5 FAIL (RED): humanizeLeadMinutes only accepts one argument.
2026-06-14 08:12:56 -04:00

907 lines
34 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`
* 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);
});
});
// ── 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.
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
});
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});
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');
});
});