Files
familysync/apps/api/tests/broker/reminderScheduler.test.ts
T
Lucas Berger 57f9d67685 feat(11-02): Task 2 — humanizeLeadMinutes tests + body dispatch assertion (D-09)
- Add 8 bucket tests: 30→'30 min', 59→'59 min', 60→'1 hr', 90→'1 hr',
  120→'2 hrs', 1440→'1 day', 2880→'2 days', 10080→'7 days'
- Add body-in-dispatch test: 1440-min lead → body='Starts in 1 day'
  (driven by configured lead, not live minutes-to-start delta)
- humanizeLeadMinutes implementation already committed in Task 1 GREEN
- All 23 tests GREEN
2026-06-13 22:22:03 -04:00

710 lines
26 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');
});
});
// ── 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
});
});