- 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
1048 lines
40 KiB
TypeScript
1048 lines
40 KiB
TypeScript
/**
|
||
* reminderScheduler tests.
|
||
*
|
||
* Asserts that the reminder scan:
|
||
* - Selects ONLY shared (isShared=true) AND timed (allDay=false) events
|
||
* in the catch-up window (now, now+16min] (D-05/D-07)
|
||
* - Does NOT dispatch reminders for all-day events (D-07)
|
||
* - Does NOT dispatch reminders for non-shared (personal) events (D-05)
|
||
* - Does NOT dispatch for an event whose dtstart <= now (already started)
|
||
* - Fires EXACTLY ONCE across consecutive ticks while the event sits in the window
|
||
* (per-uid exactly-once dedup; cross-tick double-fire prevention)
|
||
* - Recovers from a missed/late cron tick: fires at a later scan when event is
|
||
* still in (now, now+16min] even if the ideal 15-min tick was skipped
|
||
* - WR-01: sentReminders.set(uid) is called AFTER dispatch (at-least-once delivery)
|
||
* - CR-01: sentReminders Map is pruned of started events after each scan
|
||
* - D-16: empty push_subscriptions -> zero sends / no crash
|
||
* - T-05-19: per-subscription error isolation
|
||
*
|
||
* NEW (Plan 11-02):
|
||
* - NOTIF-04: fires at T-reminderLeadMinutes (variable per-event lead), not fixed 16-min window
|
||
* - NOTIF-05: NULL lead → no push; timed 0-lead → no push; personal events DO dispatch
|
||
* - NOTIF-06: uid:dtstartMs compound dedup; rescheduled dtstart re-fires; all-day 9 AM branch
|
||
* - D-09: humanized push body (humanizeLeadMinutes)
|
||
*
|
||
* Run: pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts
|
||
*/
|
||
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||
|
||
// Mock the DB so we can control what events are returned
|
||
vi.mock('../../src/db/client.js', () => ({
|
||
db: {
|
||
select: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
// Mock pushDispatcher so no real push occurs
|
||
vi.mock('../../src/lib/pushDispatcher.js', () => ({
|
||
dispatchPush: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Make a select mock chain that resolves `.where()` with the given rows.
|
||
* Supports both one-join and two-join chains (from().innerJoin[.innerJoin]().where()).
|
||
*/
|
||
function makeSelectMock(rows: unknown[]) {
|
||
const whereResolve = vi.fn().mockResolvedValue(rows);
|
||
const innerJoinLevel2 = {
|
||
where: whereResolve,
|
||
innerJoin: vi.fn().mockReturnValue({ where: whereResolve }),
|
||
};
|
||
return {
|
||
from: vi.fn().mockReturnValue({
|
||
innerJoin: vi.fn().mockReturnValue(innerJoinLevel2),
|
||
}),
|
||
} as never;
|
||
}
|
||
|
||
/**
|
||
* Setup the db.select mock so the FIRST call (timed query) returns `timedRows`,
|
||
* 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" (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');
|
||
});
|
||
});
|
||
|
||
// ── 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();
|
||
});
|
||
});
|