/** * 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 }; // ── 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); }); });