From 94daca3c7a08db089261d6fbd451887d97c085fe Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 22:24:25 -0400 Subject: [PATCH] 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 --- apps/api/tests/broker/outboxWorker.test.ts | 106 +++++++++++++++ .../tests/broker/reminderScheduler.test.ts | 123 ++++++++++++++++++ 2 files changed, 229 insertions(+) diff --git a/apps/api/tests/broker/outboxWorker.test.ts b/apps/api/tests/broker/outboxWorker.test.ts index 34293c3..14c784a 100644 --- a/apps/api/tests/broker/outboxWorker.test.ts +++ b/apps/api/tests/broker/outboxWorker.test.ts @@ -1094,3 +1094,109 @@ describe('runOutboxDrain — WR-02: reminderLeadMinutes max(10080) in outboxPayl expect(setArg?.status).not.toBe('failed'); }); }); + +// ─── Plan 18-03: stored household_timezone drives all-day alert instant ─────── +// +// D-05: when getHouseholdTimezone(db) returns 'America/Chicago', the CREATE and +// UPDATE all-day branches must compute the VALARM TRIGGER using Chicago +// local time, not process.env.TZ. +// D-06: existing tests that do not mock app_config continue to fall back to the +// current process.env.TZ / Intl behavior (backward-compat path). +// +// These tests FAIL before the rewire (GREEN) because the code still reads +// process.env.TZ at both sites. + +describe('runOutboxDrain — Plan 18-03: stored household_timezone drives all-day alert (D-05)', () => { + // Helper: wire the full db mock chain including app_config for getHouseholdTimezone. + // The app_config SELECT chain: select({value}).from(appConfig).where(...).limit(1) + // → from(app_config) → { where: fn → limitFn } → limitFn() → [{value}] + function wireMockChainWithTz(storedTz: string | null) { + wireMockChain(); // base wiring for outbox/credentials/events + + // Extend mockFromFn to also handle app_config table with where().limit() chain + const baseMockFromFn = mockFromFn.getMockImplementation(); + mockFromFn.mockImplementation((table: unknown) => { + const tableName = (table as Record)[Symbol.for('drizzle:Name')] ?? ''; + if (tableName === 'app_config') { + const tzRows = storedTz ? [{ value: storedTz }] : []; + const limitResolve = vi.fn().mockResolvedValue(tzRows); + // .where() must return an object with .limit(), not the fn itself + return { where: vi.fn().mockReturnValue({ limit: limitResolve }) }; + } + return baseMockFromFn ? baseMockFromFn(table) : { where: mockWherePending }; + }); + } + + beforeEach(() => { + vi.resetAllMocks(); + mockPendingRows = []; + wireMockChain(); + }); + + // Event date: 2026-06-20 (summer). Lead = 1440 min = 1 day → alert date = 2026-06-19. + // America/Chicago CDT (UTC-5): 9 AM CDT on 2026-06-19 = 2026-06-19T14:00:00Z. + // process.env.TZ is unset (UTC in test environment): 9 AM UTC on 2026-06-19 = 2026-06-19T09:00:00Z. + // When stored zone drives computation: TRIGGER must contain the Chicago time (14:00 UTC). + + it('D-05 create branch: stored America/Chicago produces 14:00 UTC trigger for 2026-06-20 all-day', async () => { + wireMockChainWithTz('America/Chicago'); + + const { createCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => { + capturedIcsString = icsString; + return makeResponse(201); + }); + + // all-day CREATE payload with 1440-min lead (1 day before) + const payload = JSON.stringify({ + title: 'Stored-TZ Birthday', + allDay: true, + start: '2026-06-20', + end: '2026-06-20', + reminderLeadMinutes: 1440, + }); + mockPendingRows = [makeRow({ payload })]; + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + expect(capturedIcsString as string).toContain('BEGIN:VALARM'); + // With stored 'America/Chicago' (CDT, UTC-5): alert date = 2026-06-19, 9 AM CDT = 14:00 UTC. + // The absolute TRIGGER value must be 20260619T140000Z (Chicago time). + expect(capturedIcsString as string).toContain('20260619T140000Z'); + }); + + it('D-05 update branch: stored America/Chicago produces 14:00 UTC trigger for 2026-06-20 all-day', async () => { + wireMockChainWithTz('America/Chicago'); + + const { updateCalendarEvent } = await import('../../src/broker/write.js'); + let capturedIcsString: unknown = null; + vi.mocked(updateCalendarEvent).mockImplementation( + async (_client, _calObjectUrl, icsString, _etag) => { + capturedIcsString = icsString; + return makeResponse(204); + }, + ); + + // all-day UPDATE payload with 1440-min lead — must also read stored TZ + const payload = JSON.stringify({ + title: 'Stored-TZ Update', + allDay: true, + start: '2026-06-20', + end: '2026-06-20', + reminderLeadMinutes: 1440, + }); + mockPendingRows = [makeRow({ operation: 'update', calendarObjectUrl: 'https://example.com/event.ics', etag: 'W/"abc"', payload })]; + + // Provide etag so WR-02 re-read path resolves (no rawVevent → falls through to all-day branch) + mockWhereCalEvents.mockResolvedValue([{ etag: 'W/"abc"' }]); + + await runOutboxDrain(); + + expect(typeof capturedIcsString).toBe('string'); + expect(capturedIcsString as string).toContain('BEGIN:VALARM'); + // Same expectation: alert date = 2026-06-19, 9 AM CDT = 14:00 UTC. + expect(capturedIcsString as string).toContain('20260619T140000Z'); + }); +}); diff --git a/apps/api/tests/broker/reminderScheduler.test.ts b/apps/api/tests/broker/reminderScheduler.test.ts index b86946d..f3a0f55 100644 --- a/apps/api/tests/broker/reminderScheduler.test.ts +++ b/apps/api/tests/broker/reminderScheduler.test.ts @@ -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 }, + 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(); + }); +});