feat(11-02): Task 3 — all-day 9 AM-local fire branch + dedup prune fix (NOTIF-06)
- Add all-day 9 AM tests: 0-lead fires at EDT alert UTC, not midnight - Add 1440-lead (day-before) and 10080-lead (7-day-before) tests - Add all-day dedup test: same uid:dtstartMs fires once across ticks - Fix all-day prune bug: store start-of-next-day as pruneMs instead of UTC midnight (which was always <= now by fire time, causing immediate prune) - Separate dtstartMs (dedup key component) from pruneMs (map cleanup value) - 28/28 tests GREEN; full API suite 314/314; tsc --noEmit clean
This commit is contained in:
@@ -183,7 +183,8 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
|||||||
{
|
{
|
||||||
uid: string;
|
uid: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
dtstartMs: number;
|
dtstartMs: number; // key component — used for compound dedup key
|
||||||
|
pruneMs: number; // when to prune: dtstartUtc for timed; end-of-event-date for all-day
|
||||||
reminderLeadMinutes: number;
|
reminderLeadMinutes: number;
|
||||||
dateStr: string;
|
dateStr: string;
|
||||||
subs: SubRow[];
|
subs: SubRow[];
|
||||||
@@ -211,6 +212,7 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
|||||||
uid: row.uid,
|
uid: row.uid,
|
||||||
title: row.title ?? null,
|
title: row.title ?? null,
|
||||||
dtstartMs,
|
dtstartMs,
|
||||||
|
pruneMs: dtstartMs, // timed: prune when event has started (dtstartUtc <= now)
|
||||||
reminderLeadMinutes: lead,
|
reminderLeadMinutes: lead,
|
||||||
dateStr: yyyyMmDd(dtstartUtc),
|
dateStr: yyyyMmDd(dtstartUtc),
|
||||||
subs: [],
|
subs: [],
|
||||||
@@ -248,11 +250,17 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
|||||||
const dtstartMs = Date.UTC(y, m - 1, d); // UTC midnight of event date
|
const dtstartMs = Date.UTC(y, m - 1, d); // UTC midnight of event date
|
||||||
const dedupKey = `${row.uid}:${dtstartMs}`;
|
const dedupKey = `${row.uid}:${dtstartMs}`;
|
||||||
|
|
||||||
|
// Prune value for all-day events: start-of-next-day UTC.
|
||||||
|
// Using UTC midnight of dtstartDate would be pruned immediately (it's in the past by 9 AM);
|
||||||
|
// using end-of-event-day ensures the entry persists through the full fire window.
|
||||||
|
const pruneMs = Date.UTC(y, m - 1, d + 1); // UTC midnight of event date + 1 day
|
||||||
|
|
||||||
if (!byKey.has(dedupKey)) {
|
if (!byKey.has(dedupKey)) {
|
||||||
byKey.set(dedupKey, {
|
byKey.set(dedupKey, {
|
||||||
uid: row.uid,
|
uid: row.uid,
|
||||||
title: row.title ?? null,
|
title: row.title ?? null,
|
||||||
dtstartMs,
|
dtstartMs,
|
||||||
|
pruneMs,
|
||||||
reminderLeadMinutes: lead,
|
reminderLeadMinutes: lead,
|
||||||
dateStr: dtstartDate,
|
dateStr: dtstartDate,
|
||||||
subs: [],
|
subs: [],
|
||||||
@@ -302,7 +310,10 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
|||||||
// WR-01: mark sent AFTER all dispatches have been attempted. Pre-marking before
|
// WR-01: mark sent AFTER all dispatches have been attempted. Pre-marking before
|
||||||
// dispatch prevents retry when dispatchPush throws — at-least-once delivery
|
// dispatch prevents retry when dispatchPush throws — at-least-once delivery
|
||||||
// requires not pre-marking.
|
// requires not pre-marking.
|
||||||
sentReminders.set(dedupKey, event.dtstartMs);
|
// Store pruneMs (not dtstartMs) so the CR-01 prune removes the entry at the right time:
|
||||||
|
// - timed: pruneMs = dtstartUtc → pruned when event starts
|
||||||
|
// - all-day: pruneMs = start-of-next-day → pruned after the event date
|
||||||
|
sentReminders.set(dedupKey, event.pruneMs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Per-event error isolation (T-05-18): one bad event never aborts remaining events.
|
// Per-event error isolation (T-05-18): one bad event never aborts remaining events.
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -652,6 +652,153 @@ describe('reminderScheduler — D-09: humanized push body in dispatch', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 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 ────────────────────────────────
|
// ── T-05-19: per-subscription error isolation ────────────────────────────────
|
||||||
|
|
||||||
describe('reminderScheduler — T-05-19: per-subscription error isolation', () => {
|
describe('reminderScheduler — T-05-19: per-subscription error isolation', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user