feat(11-02): GREEN Task 1 — variable-lead window, uid:dtstartMs dedup, drop isShared restriction

- Replace fixed 16-min window with per-event variable-lead fire-time check
- Two separate DB queries: timed (allDay=false) + all-day (allDay=true)
- Remove eq(calendars.isShared, true) — personal events now dispatch (NOTIF-05)
- Remove eq(calendarEvents.allDay, false) — all-day handled in separate query
- Add reminder_lead_minutes IS NOT NULL WHERE predicate (NOTIF-05)
- Skip timed events with reminderLeadMinutes===0 in JS (D-06: 0 on timed = None)
- Change dedup key from bare uid to uid:dtstartMs compound key (NOTIF-06)
- Update prune loop to use compound key
- Import computeAlertInstantUtc from vevent.js (Plan 11-01, wave 2 dep)
- Add humanizeLeadMinutes export (Task 2 body formatter, used in dispatch)
- Update test helper mockTwoQueries() to handle two-query dispatch pattern
- All 14 tests GREEN; tsc --noEmit clean; setInterval retained, no node-cron
This commit is contained in:
Lucas Berger
2026-06-13 22:20:51 -04:00
parent 9635aa9e8e
commit 62d3f58684
2 changed files with 261 additions and 113 deletions
+54 -31
View File
@@ -41,18 +41,41 @@ vi.mock('../../src/lib/pushDispatcher.js', () => ({
// ── 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({
innerJoin: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(rows),
}),
}),
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;
@@ -114,7 +137,7 @@ describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOT
isShared: true,
});
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]));
mockTwoQueries(vi.mocked(db), [row]);
await runReminderCheck(now);
expect(vi.mocked(dispatchPush)).toHaveBeenCalledOnce();
@@ -131,7 +154,7 @@ describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOT
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// Simulate: event would have been in old 16-min window but already past its fire time
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
mockTwoQueries(vi.mocked(db), []);
await runReminderCheck(now);
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
@@ -153,7 +176,7 @@ describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOT
isShared: false, // personal calendar — must still dispatch
});
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]));
mockTwoQueries(vi.mocked(db), [row]);
await runReminderCheck(now);
// Personal event must dispatch (isShared restriction dropped per NOTIF-05)
@@ -169,7 +192,7 @@ describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOT
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// NULL lead events are excluded by SQL WHERE (reminder_lead_minutes IS NOT NULL)
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
mockTwoQueries(vi.mocked(db), []);
await runReminderCheck(now);
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
@@ -192,7 +215,7 @@ describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOT
});
// Even if the query returned a timed-0 event, JS must skip it
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]));
mockTwoQueries(vi.mocked(db), [row]);
await runReminderCheck(now);
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
@@ -207,7 +230,7 @@ describe('reminderScheduler — variable-lead, NULL-vs-0, personal calendar (NOT
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
// gt(dtstartUtc, now) excludes already-started events; simulate by returning empty rows
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
mockTwoQueries(vi.mocked(db), []);
await runReminderCheck(now);
@@ -236,8 +259,8 @@ describe('reminderScheduler — D-16: empty push_subscriptions', () => {
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
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
// 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();
@@ -290,19 +313,19 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () =>
// Tick at t0 — fire time is exactly now (dtstartUtc - 15min = t0); dispatches once
vi.setSystemTime(t0);
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
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);
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
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);
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
mockTwoQueries(vi.mocked(db), [rowForNow()]);
await runReminderCheck(t2);
// Exactly one dispatch total: uid:dtstartMs dedup prevents re-fire on ticks 2 and 3
@@ -327,14 +350,14 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () =>
});
// First tick — fires
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]));
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);
vi.mocked(db.select).mockReturnValue(makeSelectMock([])); // empty (original started)
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
@@ -346,7 +369,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () =>
dtstartUtc: rescheduledDtstart,
reminderLeadMinutes: 15,
});
vi.mocked(db.select).mockReturnValue(makeSelectMock([rescheduledRow]));
mockTwoQueries(vi.mocked(db), [rescheduledRow]);
await runReminderCheck(rescheduledNow);
// Must fire again — new compound key uid:rescheduledDtstartMs
@@ -376,7 +399,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () =>
subAuth: 'a',
});
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]));
mockTwoQueries(vi.mocked(db), [row]);
await runReminderCheck(recoveryNow);
// Reminder must fire even though the ideal-mark tick was skipped
@@ -416,7 +439,7 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () =>
}),
];
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows));
mockTwoQueries(vi.mocked(db), rows);
await runReminderCheck(now);
// One dispatch per subscriber
@@ -469,13 +492,13 @@ describe('reminderScheduler — WR-01: mark-sent after dispatch', () => {
vi.mocked(dispatchPush).mockResolvedValue(undefined);
// First run — dispatch succeeds; uid is recorded after the loop
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
// 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 is in sentReminders; should NOT re-dispatch
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
// 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
});
@@ -524,20 +547,20 @@ describe('reminderScheduler — CR-01: sentReminders Map pruning', () => {
// Tick at t0 (now=10:00): event 15 min out — fires
const t0 = new Date('2026-06-15T10:00:00Z');
vi.setSystemTime(t0);
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
mockTwoQueries(vi.mocked(db), [eventRow]);
await runReminderCheck(t0);
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // fired
// Same t0 — uid still in Map — must NOT re-fire
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
// 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 entry.
// 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);
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
mockTwoQueries(vi.mocked(db), []);
await runReminderCheck(tPast);
// Pruning fires; dispatch count stays at 1
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1);
@@ -589,7 +612,7 @@ describe('reminderScheduler — T-05-19: per-subscription error isolation', () =
}),
];
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows));
mockTwoQueries(vi.mocked(db), rows);
// First subscriber throws; second should still be attempted
vi.mocked(dispatchPush)
.mockRejectedValueOnce(new Error('network error'))