diff --git a/apps/api/tests/broker/reminderScheduler.test.ts b/apps/api/tests/broker/reminderScheduler.test.ts index 3ca3c57..be04096 100644 --- a/apps/api/tests/broker/reminderScheduler.test.ts +++ b/apps/api/tests/broker/reminderScheduler.test.ts @@ -567,6 +567,91 @@ describe('reminderScheduler — CR-01: sentReminders Map pruning', () => { }); }); +// ── 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" (60–119 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', () => {