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:
@@ -43,6 +43,7 @@ import {
|
|||||||
} from './vevent.js';
|
} from './vevent.js';
|
||||||
import type { FastmailClient } from './client.js';
|
import type { FastmailClient } from './client.js';
|
||||||
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
import { dispatchEventChange } from '../lib/eventChangeDispatcher.js';
|
||||||
|
import { getHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
import { onOutboxDrain } from '../lib/outboxTrigger.js';
|
import { onOutboxDrain } from '../lib/outboxTrigger.js';
|
||||||
|
|
||||||
// ── Constants (D-07) ────────────────────────────────────────────────────────
|
// ── Constants (D-07) ────────────────────────────────────────────────────────
|
||||||
@@ -498,7 +499,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
fields.allDay &&
|
fields.allDay &&
|
||||||
fields.start
|
fields.start
|
||||||
) {
|
) {
|
||||||
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
// D-05: route through the single stored-TZ accessor (no inline fallback duplicated here).
|
||||||
|
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored.
|
||||||
|
const tz = await getHouseholdTimezone(db);
|
||||||
const leadDays = fields.reminderLeadMinutes / 1440;
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
allDayAlertInstantUtcUpdate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
}
|
}
|
||||||
@@ -604,7 +607,9 @@ async function dispatchRow(row: OutboxRow): Promise<DispatchResult> {
|
|||||||
// carries an explicit picker value or no reminder at all; no preserve path needed).
|
// carries an explicit picker value or no reminder at all; no preserve path needed).
|
||||||
let allDayAlertInstantUtcCreate: Date | undefined;
|
let allDayAlertInstantUtcCreate: Date | undefined;
|
||||||
if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) {
|
if (fields.reminderLeadMinutes != null && fields.allDay && fields.start) {
|
||||||
const tz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
// D-05: route through the single stored-TZ accessor (no inline fallback duplicated here).
|
||||||
|
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored.
|
||||||
|
const tz = await getHouseholdTimezone(db);
|
||||||
const leadDays = fields.reminderLeadMinutes / 1440;
|
const leadDays = fields.reminderLeadMinutes / 1440;
|
||||||
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
allDayAlertInstantUtcCreate = computeAlertInstantUtc(fields.start, leadDays, tz);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { calendarEvents, pushSubscriptions } from '../db/schema.js';
|
|||||||
import { dispatchPush } from '../lib/pushDispatcher.js';
|
import { dispatchPush } from '../lib/pushDispatcher.js';
|
||||||
import { computeAlertInstantUtc } from './vevent.js';
|
import { computeAlertInstantUtc } from './vevent.js';
|
||||||
import type { NotificationPayload } from '../lib/pushDispatcher.js';
|
import type { NotificationPayload } from '../lib/pushDispatcher.js';
|
||||||
|
import { getHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
|
|
||||||
// Maximum lead time preset (2880 min = 2 days for timed; 7 days for all-day is handled separately)
|
// Maximum lead time preset (2880 min = 2 days for timed; 7 days for all-day is handled separately)
|
||||||
// Pre-filter window upper bound for timed events: fetch events starting up to MAX_LEAD_MINUTES out.
|
// Pre-filter window upper bound for timed events: fetch events starting up to MAX_LEAD_MINUTES out.
|
||||||
@@ -244,7 +245,9 @@ export async function runReminderCheck(now = new Date()): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process ALL-DAY events
|
// Process ALL-DAY events
|
||||||
const serverTz = process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
|
// D-05: read the stored household timezone through the single accessor (no inline fallback).
|
||||||
|
// D-06: getHouseholdTimezone falls back to process.env.TZ → Intl when no row is stored.
|
||||||
|
const serverTz = await getHouseholdTimezone(db);
|
||||||
for (const row of allDayRows) {
|
for (const row of allDayRows) {
|
||||||
// Drizzle's date() column type is Date|null in TS but mysql2 returns ISO string at runtime
|
// Drizzle's date() column type is Date|null in TS but mysql2 returns ISO string at runtime
|
||||||
const dtstartDate = row.dtstartDate as unknown as string; // 'YYYY-MM-DD'
|
const dtstartDate = row.dtstartDate as unknown as string; // 'YYYY-MM-DD'
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ function wireMockChain() {
|
|||||||
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
|
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
|
||||||
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
||||||
// - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
|
// - 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)
|
// - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
|
||||||
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
|
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
|
||||||
mockFromFn.mockImplementation((table: unknown) => {
|
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 };
|
return { where: mockWherePending };
|
||||||
});
|
});
|
||||||
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]));
|
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`
|
* Setup the db.select mock so the FIRST call (timed query) returns `timedRows`,
|
||||||
* and the SECOND call (all-day query) returns `allDayRows` (default empty).
|
* 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
|
* 1st: timed events query
|
||||||
* 2nd: all-day 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(
|
function mockTwoQueries(
|
||||||
db: { select: ReturnType<typeof vi.fn> },
|
db: { select: ReturnType<typeof vi.fn> },
|
||||||
timedRows: unknown[],
|
timedRows: unknown[],
|
||||||
allDayRows: unknown[] = [],
|
allDayRows: unknown[] = [],
|
||||||
) {
|
) {
|
||||||
|
// Reset pending once-values to prevent cross-test queue contamination.
|
||||||
|
db.select.mockReset();
|
||||||
db.select
|
db.select
|
||||||
.mockReturnValueOnce(makeSelectMock(timedRows))
|
.mockReturnValueOnce(makeSelectMock(timedRows))
|
||||||
.mockReturnValueOnce(makeSelectMock(allDayRows));
|
.mockReturnValueOnce(makeSelectMock(allDayRows))
|
||||||
|
.mockReturnValueOnce(makeAppConfigSelectMock(null)); // null → D-06 fallback (no stored TZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeEventRow(overrides: {
|
function makeEventRow(overrides: {
|
||||||
|
|||||||
Reference in New Issue
Block a user