test(18-01): add failing tests for household timezone accessor + IANA validator
- 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
This commit is contained in:
@@ -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<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');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user