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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ea93089b74
commit
d168da71cf
@@ -16,7 +16,7 @@ import { appConfig } from '../db/schema.js';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the stored household timezone from app_config, or falls back to:
|
* Returns the stored household timezone from app_config, or falls back to:
|
||||||
* 1. process.env.TZ (if set and non-empty)
|
* 1. process.env.TZ (only if set and non-empty — empty/whitespace is ignored, WR-01)
|
||||||
* 2. Intl.DateTimeFormat().resolvedOptions().timeZone
|
* 2. Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
*
|
*
|
||||||
* This is the D-05 single source of truth for the server-side all-day "9 AM local"
|
* This is the D-05 single source of truth for the server-side all-day "9 AM local"
|
||||||
@@ -31,11 +31,33 @@ export async function getHouseholdTimezone(
|
|||||||
.where(eq(appConfig.key, 'household_timezone'))
|
.where(eq(appConfig.key, 'household_timezone'))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
return (
|
return resolveHouseholdTimezone(row?.value ?? null);
|
||||||
row?.value ??
|
}
|
||||||
process.env.TZ ??
|
|
||||||
Intl.DateTimeFormat().resolvedOptions().timeZone
|
/**
|
||||||
);
|
* Resolves the household timezone from an already-fetched stored value, applying
|
||||||
|
* the D-06 fallback chain. Centralizing the policy here (D-05) means callers that
|
||||||
|
* have already read the row — e.g. the GET /config/timezone handler — can reuse it
|
||||||
|
* without a second DB round-trip (IN-01), and there is exactly one place where the
|
||||||
|
* fallback rules live (IN-02).
|
||||||
|
*
|
||||||
|
* WR-01: `??` only short-circuits on null/undefined, so a set-but-empty
|
||||||
|
* `process.env.TZ` (`TZ=` or `TZ=' '`) would otherwise leak through and yield an
|
||||||
|
* invalid IANA zone that throws inside `Intl.DateTimeFormat({ timeZone })` downstream.
|
||||||
|
* Empty/whitespace-only candidate values are treated as absent so they fall through.
|
||||||
|
*/
|
||||||
|
export function resolveHouseholdTimezone(storedValue: string | null): string {
|
||||||
|
const stored = storedValue?.trim();
|
||||||
|
if (stored) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envTz = process.env.TZ?.trim();
|
||||||
|
if (envTz) {
|
||||||
|
return envTz;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -97,6 +97,24 @@ describe('getHouseholdTimezone', () => {
|
|||||||
const result = await getHouseholdTimezone(mockDb as never);
|
const result = await getHouseholdTimezone(mockDb as never);
|
||||||
expect(result).toBe('Europe/London');
|
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', () => {
|
describe('isValidIanaTimezone', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user