Files
familysync/apps/api/tests/lib/householdTimezone.test.ts
T
Lucas BergerandClaude Opus 4.8 d168da71cf fix(18): WR-01 treat empty/blank TZ as unset in household timezone fallback
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>
2026-06-15 07:38:39 -04:00

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);
});
});