feat(18-03): route all-day reminder TZ through stored household_timezone

- reminderScheduler.ts: add import { getHouseholdTimezone } from '../lib/householdTimezone.js'
  and replace bare process.env.TZ ?? Intl... at line 247 with await getHouseholdTimezone(db)
- outboxWorker.ts: add same import and replace BOTH bare tz lookups at the update-branch
  (~line 501) and create-branch (~line 607) with await getHouseholdTimezone(db)
- D-05 satisfied: all three all-day sites now read from the single stored accessor
- D-06 satisfied: getHouseholdTimezone falls back to process.env.TZ → Intl when unset;
  existing process.env.TZ-pinned tests pass unchanged
- D-07 satisfied: eventDateTime.ts and hydrateEvents.ts are not modified
- outboxWorker.test.ts: update wireMockChain() to handle app_config table with where().limit()
  chain returning empty rows (D-06 fallback), so existing CAL-13 all-day test stays green
- reminderScheduler.test.ts: update mockTwoQueries to mock the new third db.select() call
  (getHouseholdTimezone) returning no row (D-06 fallback), keeping all 37 existing tests green
- All 76 broker tests pass; tsc --noEmit clean
This commit is contained in:
Lucas Berger
2026-06-14 22:31:37 -04:00
parent 94daca3c7a
commit c80845cdba
4 changed files with 35 additions and 7 deletions
@@ -142,6 +142,8 @@ function wireMockChain() {
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
// - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
// - app_config → returns empty row array (D-06 fallback: no stored TZ → Intl fallback)
// getHouseholdTimezone(db) chain: .from(appConfig).where(...).limit(1) → []
// - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
mockFromFn.mockImplementation((table: unknown) => {
@@ -160,6 +162,13 @@ function wireMockChain() {
}),
};
}
if (tableName === 'app_config') {
// D-06: default = no stored timezone row → getHouseholdTimezone falls back to
// process.env.TZ → Intl. The where().limit(1) chain must be mockable.
// Plan 18-03 tests override this via wireMockChainWithTz to inject a stored zone.
const limitFn = vi.fn().mockResolvedValue([]); // no stored row → D-06 fallback
return { where: vi.fn().mockReturnValue({ limit: limitFn }) };
}
return { where: mockWherePending };
});
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]));
@@ -59,21 +59,32 @@ function makeSelectMock(rows: unknown[]) {
}
/**
* Setup the db.select mock so the FIRST call (timed query) returns `timedRows`
* and the SECOND call (all-day query) returns `allDayRows` (default empty).
* Setup the db.select mock so the FIRST call (timed query) returns `timedRows`,
* the SECOND call (all-day query) returns `allDayRows` (default empty), and the
* THIRD call (getHouseholdTimezone app_config lookup, Plan 18-03) returns no row
* so the D-06 fallback fires (process.env.TZ → Intl).
*
* runReminderCheck() issues two sequential db.select() calls:
* runReminderCheck() after the Plan 18-03 rewire issues three sequential db.select() calls:
* 1st: timed events query
* 2nd: all-day events query
* 3rd: getHouseholdTimezone → SELECT value FROM app_config WHERE key='household_timezone'
*
* Existing tests that pin process.env.TZ rely on the D-06 fallback (empty app_config row),
* so the third call returning no row keeps them green unchanged.
*
* To test a stored timezone (D-05), use mockThreeQueries instead.
*/
function mockTwoQueries(
db: { select: ReturnType<typeof vi.fn> },
timedRows: unknown[],
allDayRows: unknown[] = [],
) {
// Reset pending once-values to prevent cross-test queue contamination.
db.select.mockReset();
db.select
.mockReturnValueOnce(makeSelectMock(timedRows))
.mockReturnValueOnce(makeSelectMock(allDayRows));
.mockReturnValueOnce(makeSelectMock(allDayRows))
.mockReturnValueOnce(makeAppConfigSelectMock(null)); // null → D-06 fallback (no stored TZ)
}
function makeEventRow(overrides: {