test(18-03): add failing stored-TZ all-day tests for scheduler + outbox

- reminderScheduler: new describe block with mockThreeQueries helper that
  extends mockTwoQueries to mock getHouseholdTimezone app_config SELECT
  (select({value}).from(appConfig).where(...).limit(1) chain)
- reminderScheduler: D-05 test expects dispatch at 14:00 UTC (Chicago CDT)
  when stored zone is America/Chicago; fails RED (code still reads process.env.TZ=America/New_York)
- reminderScheduler: D-05 NOT-fire test expects no dispatch at 13:00 UTC (NY time)
  when stored zone overrides to Chicago; fails RED (code fires at NY time)
- outboxWorker: new describe block with wireMockChainWithTz that extends
  mockFromFn to handle app_config table via where().limit() chain
- outboxWorker: D-05 create-branch test expects VALARM TRIGGER 20260619T140000Z
  (Chicago CDT); fails RED (code emits 20260619T130000Z using UTC fallback)
- outboxWorker: D-05 update-branch test same assertion, also fails RED
- Existing process.env.TZ-pinned all-day tests untouched; all 72 pass
This commit is contained in:
Lucas Berger
2026-06-14 22:24:25 -04:00
parent 08c39165bf
commit 94daca3c7a
2 changed files with 229 additions and 0 deletions
@@ -911,3 +911,126 @@ describe('humanizeLeadMinutes — CR-02 all-day-aware push body', () => {
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();
});
});