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