From db0077c3c3a5a6bb3fde2648226b3f99c5256075 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sun, 14 Jun 2026 22:05:58 -0400 Subject: [PATCH] test(18-01): add failing tests for household timezone accessor + IANA validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RED gate: tests for getHouseholdTimezone fallback chain (stored → TZ env → Intl) - Tests for null row value falling through to TZ env branch - Tests for isValidIanaTimezone (UTC, Etc/UTC, America/Chicago, Europe/London pass; garbage fails) - Mock Drizzle select chain follows requireAdmin.test.ts pattern - Saves/restores process.env.TZ in beforeEach/afterEach to prevent env state leaks --- apps/api/tests/lib/householdTimezone.test.ts | 130 +++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 apps/api/tests/lib/householdTimezone.test.ts diff --git a/apps/api/tests/lib/householdTimezone.test.ts b/apps/api/tests/lib/householdTimezone.test.ts new file mode 100644 index 0000000..405b6b2 --- /dev/null +++ b/apps/api/tests/lib/householdTimezone.test.ts @@ -0,0 +1,130 @@ +/** + * 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'); + }); +}); + +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); + }); +});