The D-06 fallback used row?.value ?? process.env.TZ ?? Intl..., but ??
only short-circuits on null/undefined. A set-but-empty TZ ('' or ' ')
leaked through and yielded an invalid IANA zone that throws inside
Intl.DateTimeFormat({ timeZone }) downstream, silently dropping the
all-day reminder. Extract resolveHouseholdTimezone() which trims and
treats empty/whitespace candidate values (stored value and TZ) as
absent so they fall through to the Intl resolved zone. Adds RED->GREEN
unit tests for empty and whitespace-only TZ.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
/**
|
|
* householdTimezone unit tests (Plan 18-01)
|
|
*
|
|
* Behavior-pinned contracts:
|
|
* 1. getHouseholdTimezone: DB row present → returns stored value
|
|
* 2. getHouseholdTimezone: no DB row, process.env.TZ set → returns TZ value
|
|
* 3. getHouseholdTimezone: no DB row, no TZ env var → returns Intl.DateTimeFormat().resolvedOptions().timeZone
|
|
* 4. getHouseholdTimezone: DB row present but value is null → falls through to env.TZ branch
|
|
* 5. isValidIanaTimezone: returns true for valid IANA zones ('UTC', 'Etc/UTC', 'America/Chicago', 'Europe/London')
|
|
* 6. isValidIanaTimezone: returns false for garbage ('Not/AZone', '', 'Mars/Phobos')
|
|
*
|
|
* Uses a mocked Drizzle db chain (same pattern as requireAdmin.test.ts).
|
|
* Saves and restores process.env.TZ around each test to avoid leaking env state.
|
|
*
|
|
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/householdTimezone.test.ts
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
// Mock the db singleton so tests do not need a live MariaDB connection.
|
|
vi.mock('../../src/db/client.js', () => ({
|
|
db: {
|
|
select: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
import { db } from '../../src/db/client.js';
|
|
import { getHouseholdTimezone, isValidIanaTimezone } from '../../src/lib/householdTimezone.js';
|
|
|
|
const mockDb = db as { select: ReturnType<typeof vi.fn> };
|
|
|
|
// ── DB query chain builder ────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Builds a Drizzle select() → from() → where() → limit() chain
|
|
* that resolves to the given array.
|
|
*/
|
|
function makeSelectChain(resolvedValue: unknown[]) {
|
|
const chain = {
|
|
from: vi.fn(),
|
|
where: vi.fn(),
|
|
limit: vi.fn().mockResolvedValue(resolvedValue),
|
|
};
|
|
chain.from.mockReturnValue(chain);
|
|
chain.where.mockReturnValue(chain);
|
|
return chain;
|
|
}
|
|
|
|
// ── Test suite ────────────────────────────────────────────────────────────────
|
|
|
|
describe('getHouseholdTimezone', () => {
|
|
let originalTZ: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Save the current TZ env value before each test
|
|
originalTZ = process.env.TZ;
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Restore TZ to its original value (or delete if it was unset)
|
|
if (originalTZ === undefined) {
|
|
delete process.env.TZ;
|
|
} else {
|
|
process.env.TZ = originalTZ;
|
|
}
|
|
});
|
|
|
|
it('returns the stored value when the DB has a row for household_timezone', async () => {
|
|
mockDb.select.mockReturnValue(makeSelectChain([{ value: 'America/Chicago' }]));
|
|
|
|
const result = await getHouseholdTimezone(mockDb as never);
|
|
expect(result).toBe('America/Chicago');
|
|
});
|
|
|
|
it('falls back to process.env.TZ when the DB returns no row', async () => {
|
|
mockDb.select.mockReturnValue(makeSelectChain([]));
|
|
process.env.TZ = 'America/New_York';
|
|
|
|
const result = await getHouseholdTimezone(mockDb as never);
|
|
expect(result).toBe('America/New_York');
|
|
});
|
|
|
|
it('falls back to Intl.DateTimeFormat().resolvedOptions().timeZone when no DB row and TZ is unset', async () => {
|
|
mockDb.select.mockReturnValue(makeSelectChain([]));
|
|
delete process.env.TZ;
|
|
|
|
const expected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
const result = await getHouseholdTimezone(mockDb as never);
|
|
expect(result).toBe(expected);
|
|
});
|
|
|
|
it('falls through to process.env.TZ when the DB row value is null', async () => {
|
|
mockDb.select.mockReturnValue(makeSelectChain([{ value: null }]));
|
|
process.env.TZ = 'Europe/London';
|
|
|
|
const result = await getHouseholdTimezone(mockDb as never);
|
|
expect(result).toBe('Europe/London');
|
|
});
|
|
|
|
it('treats an empty process.env.TZ as unset and falls through to the Intl zone (WR-01)', async () => {
|
|
mockDb.select.mockReturnValue(makeSelectChain([]));
|
|
process.env.TZ = '';
|
|
|
|
const expected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
const result = await getHouseholdTimezone(mockDb as never);
|
|
expect(result).toBe(expected);
|
|
});
|
|
|
|
it('treats a whitespace-only process.env.TZ as unset and falls through to the Intl zone (WR-01)', async () => {
|
|
mockDb.select.mockReturnValue(makeSelectChain([]));
|
|
process.env.TZ = ' ';
|
|
|
|
const expected = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
const result = await getHouseholdTimezone(mockDb as never);
|
|
expect(result).toBe(expected);
|
|
});
|
|
});
|
|
|
|
describe('isValidIanaTimezone', () => {
|
|
it("returns true for 'UTC'", () => {
|
|
expect(isValidIanaTimezone('UTC')).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'Etc/UTC'", () => {
|
|
expect(isValidIanaTimezone('Etc/UTC')).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'America/Chicago'", () => {
|
|
expect(isValidIanaTimezone('America/Chicago')).toBe(true);
|
|
});
|
|
|
|
it("returns true for 'Europe/London'", () => {
|
|
expect(isValidIanaTimezone('Europe/London')).toBe(true);
|
|
});
|
|
|
|
it("returns false for 'Not/AZone'", () => {
|
|
expect(isValidIanaTimezone('Not/AZone')).toBe(false);
|
|
});
|
|
|
|
it("returns false for empty string ''", () => {
|
|
expect(isValidIanaTimezone('')).toBe(false);
|
|
});
|
|
|
|
it("returns false for 'Mars/Phobos'", () => {
|
|
expect(isValidIanaTimezone('Mars/Phobos')).toBe(false);
|
|
});
|
|
});
|