Phase 18: Auto timezone detection and ability to change timezone #21
@@ -1094,3 +1094,109 @@ describe('runOutboxDrain — WR-02: reminderLeadMinutes max(10080) in outboxPayl
|
|||||||
expect(setArg?.status).not.toBe('failed');
|
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, string>)[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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -911,3 +911,126 @@ describe('humanizeLeadMinutes — CR-02 all-day-aware push body', () => {
|
|||||||
expect(humanizeLeadMinutes(30)).toBe('Starts in 30 min');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user