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:
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user