Persist an explicit timezone choice (timezone_configured)

The admin settings screen auto-set the browser zone whenever it loaded an
empty timezone, guarded only by a per-mount ref. After an admin cleared the
timezone (empty = API-host-local), the next open re-fetched "" and
auto-set the browser zone again, so the empty choice could not persist.

Add a twelfth settings field, timezone_configured (strict bool, default
false), carried by the core settings table, the stored JSON document and
the admin GET/PATCH. The screen now auto-sets only when the timezone is
empty AND timezone_configured is false, patching the flag with the
detected zone; saving the timezone field (including clearing it) also
sends timezone_configured: true. Detection returning nothing sets nothing.

Task: a0e7649c-58b0-4aa3-91b5-cf426e334a76 (release review TZ-001)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 13:12:45 -04:00
co-authored by Claude Opus 5.5
parent 09972ccbfe
commit 775c7704d9
9 changed files with 146 additions and 28 deletions
+6
View File
@@ -19,6 +19,12 @@ export interface HouseholdSettings {
celebration_enabled: boolean; celebration_enabled: boolean;
/** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */ /** IANA zone name (e.g. "America/New_York"); "" means the API host's local time. */
timezone: string; timezone: string;
/**
* True once the admin explicitly chose `timezone` (even ""); false on a
* fresh install, where the screen auto-sets the browser's zone. API-only:
* never shown in the form.
*/
timezone_configured: boolean;
} }
/** Only the supplied fields are updated; the API answers with all of them. */ /** Only the supplied fields are updated; the API answers with all of them. */
+29 -7
View File
@@ -23,6 +23,7 @@ const DEFAULT_SETTINGS: HouseholdSettings = {
end_of_day_report_enabled: true, end_of_day_report_enabled: true,
celebration_enabled: false, celebration_enabled: false,
timezone: "America/Chicago", timezone: "America/Chicago",
timezone_configured: true,
}; };
function child( function child(
@@ -506,7 +507,9 @@ describe("SettingsScreen — preferences", () => {
fireEvent.change(input, { target: { value: " Europe/London " } }); fireEvent.change(input, { target: { value: " Europe/London " } });
save(form); save(form);
await screen.findByText("Preferences saved."); await screen.findByText("Preferences saved.");
expect(writes()).toEqual([{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London" } }]); expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London", timezone_configured: true } },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" }); const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe("Europe/London"); expect(field(reloaded, "Timezone").value).toBe("Europe/London");
}); });
@@ -572,7 +575,8 @@ describe("SettingsScreen — timezone auto-set", () => {
sessionStorage.clear(); sessionStorage.clear();
storeTokens({ access_token: "at-123", expires_in: 600 }); storeTokens({ access_token: "at-123", expires_in: 600 });
children = [child(1, "Declan", 1)]; children = [child(1, "Declan", 1)];
settings = { ...DEFAULT_SETTINGS, timezone: "" }; // A fresh install: no zone, and the admin never chose one.
settings = { ...DEFAULT_SETTINGS, timezone: "", timezone_configured: false };
writeResponse = null; writeResponse = null;
zoneSpy = null; zoneSpy = null;
fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init)); fetchMock = vi.fn(async (url: string, init?: RequestInit) => routeFetch(url, init));
@@ -596,9 +600,10 @@ describe("SettingsScreen — timezone auto-set", () => {
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe("America/New_York"); expect(field(form, "Timezone").value).toBe("America/New_York");
expect(writes()).toEqual([ expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } }, { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
]); ]);
expect(settings.timezone).toBe("America/New_York"); expect(settings.timezone).toBe("America/New_York");
expect(settings.timezone_configured).toBe(true);
// A later save of another field does not re-send the timezone. // A later save of another field does not re-send the timezone.
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } }); fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
@@ -618,24 +623,41 @@ describe("SettingsScreen — timezone auto-set", () => {
const form = await screen.findByRole("form", { name: "Preferences" }); const form = await screen.findByRole("form", { name: "Preferences" });
expect(field(form, "Timezone").value).toBe("America/New_York"); expect(field(form, "Timezone").value).toBe("America/New_York");
expect(writes()).toEqual([ expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York" } }, { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } },
]); ]);
}); });
it("an admin clearing the timezone is not re-set by the browser zone", async () => { it("an admin clearing the timezone is not re-set by the browser zone, even after a reopen", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
const form = await renderForm(); const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } }); fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" })); fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved."); await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ timezone: "America/New_York" }, { timezone: "" }]); expect(writes().map((w) => w.body)).toEqual([
{ timezone: "America/New_York", timezone_configured: true },
{ timezone: "", timezone_configured: true },
]);
const reloaded = screen.getByRole("form", { name: "Preferences" }); const reloaded = screen.getByRole("form", { name: "Preferences" });
expect(field(reloaded, "Timezone").value).toBe(""); expect(field(reloaded, "Timezone").value).toBe("");
// Close and reopen: the screen re-fetches "" but the choice persisted.
cleanup();
const reopened = await renderForm();
expect(field(reopened, "Timezone").value).toBe("");
expect(writes()).toHaveLength(2);
});
it("an empty timezone the admin chose is never auto-set", async () => {
mockBrowserZone("America/New_York");
settings = { ...settings, timezone_configured: true };
const form = await renderForm();
expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]);
}); });
it("a set timezone is shown and never overwritten by the browser zone", async () => { it("a set timezone is shown and never overwritten by the browser zone", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" }; settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin", timezone_configured: false };
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe("Europe/Dublin"); expect(field(form, "Timezone").value).toBe("Europe/Dublin");
expect(writes()).toEqual([]); expect(writes()).toEqual([]);
+13 -7
View File
@@ -5,8 +5,9 @@
* household's child profiles. Every write refetches the list. * household's child profiles. Every write refetches the list.
* *
* Preferences: the household settings (planning horizon, day rollover, * Preferences: the household settings (planning horizon, day rollover,
* timezone, notifications). Save patches only the changed fields. An unset * timezone, notifications). Save patches only the changed fields. A timezone
* timezone is set once, on load, to the browser's own zone. * the admin never chose is set once, on load, to the browser's own zone;
* saving the timezone (even clearing it) marks it chosen, so it sticks.
* *
* One write at a time across both sections. * One write at a time across both sections.
*/ */
@@ -341,6 +342,9 @@ function buildChanges(draft: PreferencesDraft, loaded: HouseholdSettings): Setti
for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) { for (const key of Object.keys(loaded) as (keyof HouseholdSettings)[]) {
if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key]; if (next[key] !== loaded[key]) (changes as Record<string, unknown>)[key] = next[key];
} }
// An explicit choice — including "" (the API host's local time) — must
// persist, or the next load would auto-set the browser's zone over it.
if ("timezone" in changes) changes.timezone_configured = true;
if (Object.keys(changes).length === 0) return "No changes to save."; if (Object.keys(changes).length === 0) return "No changes to save.";
return changes; return changes;
} }
@@ -570,7 +574,7 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
} | null>(null); } | null>(null);
// A ref, not state: two taps in one frame must not both see `busy === null`. // A ref, not state: two taps in one frame must not both see `busy === null`.
const inFlight = useRef(false); const inFlight = useRef(false);
// The unset-timezone auto-set fires at most once per screen. // The never-chosen-timezone auto-set fires at most once per screen.
const timezoneAutoSet = useRef(false); const timezoneAutoSet = useRef(false);
const screenRef = useRef<HTMLDivElement>(null); const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null); const titleRef = useRef<HTMLHeadingElement>(null);
@@ -618,16 +622,18 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
fetchSettings() fetchSettings()
.then(async (settings) => { .then(async (settings) => {
if (cancelled) return; if (cancelled) return;
// Unset: adopt the browser's zone before the form shows, so nothing // Never chosen: adopt the browser's zone before the form shows, so
// the admin enters can be overwritten by it. // nothing the admin enters can be overwritten by it. An empty zone the
const detected = settings.timezone === "" ? detectTimezone() : ""; // admin chose (timezone_configured) is left alone.
const unchosen = settings.timezone === "" && !settings.timezone_configured;
const detected = unchosen ? detectTimezone() : "";
if (!detected || timezoneAutoSet.current) { if (!detected || timezoneAutoSet.current) {
setPreferences({ kind: "ready", settings }); setPreferences({ kind: "ready", settings });
return; return;
} }
timezoneAutoSet.current = true; timezoneAutoSet.current = true;
try { try {
const saved = await updateSettings({ timezone: detected }); const saved = await updateSettings({ timezone: detected, timezone_configured: true });
if (!cancelled) setPreferences({ kind: "ready", settings: saved }); if (!cancelled) setPreferences({ kind: "ready", settings: saved });
} catch (error) { } catch (error) {
if (cancelled) return; if (cancelled) return;
+12 -7
View File
@@ -165,12 +165,13 @@ integration's Home-Assistant config-entry options remain a temporary,
SEPARATE source until Feature 18 wires the client to the API — the two SEPARATE source until Feature 18 wires the client to the API — the two
are deliberately not unified here: are deliberately not unified here:
- ``GET /settings`` returns the current effective settings: all eleven - ``GET /settings`` returns the current effective settings: all twelve
documented fields (the rolling horizon, the day rollover time, the documented fields (the rolling horizon, the day rollover time, the
notify target, the three notification times, the four enable notify target, the three notification times, the four enable
toggles and the ``timezone`` — the IANA household zone the toggles, the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local) — the household-local dates derive from, empty meaning host local — and
defaults on a fresh database. ``timezone_configured``, whether the admin explicitly chose that
zone) — the defaults on a fresh database.
- ``PATCH /settings`` updates ONLY the supplied fields: the body is a - ``PATCH /settings`` updates ONLY the supplied fields: the body is a
JSON object of settings field names to new values, passed STRAIGHT JSON object of settings field names to new values, passed STRAIGHT
to :func:`nestquest_core.settings_store.update_settings` — the to :func:`nestquest_core.settings_store.update_settings` — the
@@ -905,7 +906,7 @@ class AdminSettingsResponse(BaseModel):
"""The effective settings as the admin plane serializes them. """The effective settings as the admin plane serializes them.
Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings` Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings`
— all ELEVEN documented fields, resolved (every absent stored field — all TWELVE documented fields, resolved (every absent stored field
already fell back to its default in the core). The same shape both already fell back to its default in the core). The same shape both
the GET and the PATCH route answer with. the GET and the PATCH route answer with.
""" """
@@ -923,6 +924,9 @@ class AdminSettingsResponse(BaseModel):
#: The household IANA time zone the API reads its clock in; ``""`` #: The household IANA time zone the API reads its clock in; ``""``
#: means the API host's local time. #: means the API host's local time.
timezone: str timezone: str
#: Whether the admin explicitly chose ``timezone`` (even ``""``);
#: ``False`` lets the admin UI auto-set the browser's zone once.
timezone_configured: bool
class AdminMissedSweepResponse(BaseModel): class AdminMissedSweepResponse(BaseModel):
@@ -2179,7 +2183,7 @@ def _settings_response(
"""Serialize one core NestQuestSettings into the documented payload. """Serialize one core NestQuestSettings into the documented payload.
Explicit field-by-field (not ``dataclasses.asdict``) so the payload Explicit field-by-field (not ``dataclasses.asdict``) so the payload
stays pinned to the eleven documented fields even if the core stays pinned to the twelve documented fields even if the core
dataclass later grows another one. dataclass later grows another one.
""" """
return AdminSettingsResponse( return AdminSettingsResponse(
@@ -2194,6 +2198,7 @@ def _settings_response(
end_of_day_report_enabled=settings.end_of_day_report_enabled, end_of_day_report_enabled=settings.end_of_day_report_enabled,
celebration_enabled=settings.celebration_enabled, celebration_enabled=settings.celebration_enabled,
timezone=settings.timezone, timezone=settings.timezone,
timezone_configured=settings.timezone_configured,
) )
@@ -2291,7 +2296,7 @@ async def admin_history_csv(
response_model=AdminSettingsResponse, response_model=AdminSettingsResponse,
) )
async def admin_get_settings(request: Request) -> AdminSettingsResponse: async def admin_get_settings(request: Request) -> AdminSettingsResponse:
"""Return the current effective settings (all eleven documented fields). """Return the current effective settings (all twelve documented fields).
Requires a valid admin JWT (the router's shared Requires a valid admin JWT (the router's shared
:func:`~api.auth.require_admin` dependency — the ONE check; this :func:`~api.auth.require_admin` dependency — the ONE check; this
@@ -119,6 +119,12 @@ DEFAULT_AUTOMATION_ENABLED = True
# Home Assistant's configured ``time_zone`` and ignores this field. # Home Assistant's configured ``time_zone`` and ignores this field.
CONF_TIMEZONE = "timezone" CONF_TIMEZONE = "timezone"
DEFAULT_TIMEZONE = "" DEFAULT_TIMEZONE = ""
# Whether the admin has made an explicit ``timezone`` choice. ``False``
# (a fresh install) lets the admin UI auto-set the browser's zone once;
# ``True`` pins the stored value — including an explicitly-chosen empty
# ``timezone`` (the API host's local time) — so it is never auto-set again.
CONF_TIMEZONE_CONFIGURED = "timezone_configured"
DEFAULT_TIMEZONE_CONFIGURED = False
# Panel-plane API client (Feature 18): the integration reaches the # Panel-plane API client (Feature 18): the integration reaches the
# NestQuest API service (Feature 16) over HTTP instead of the database. # NestQuest API service (Feature 16) over HTTP instead of the database.
@@ -49,6 +49,7 @@ from .const import (
CONF_MORNING_SUMMARY_TIME, CONF_MORNING_SUMMARY_TIME,
CONF_NOTIFY_TARGET, CONF_NOTIFY_TARGET,
CONF_TIMEZONE, CONF_TIMEZONE,
CONF_TIMEZONE_CONFIGURED,
DEFAULT_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME,
DEFAULT_AUTOMATION_ENABLED, DEFAULT_AUTOMATION_ENABLED,
DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME,
@@ -56,6 +57,7 @@ from .const import (
DEFAULT_HORIZON_DAYS, DEFAULT_HORIZON_DAYS,
DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME,
DEFAULT_TIMEZONE, DEFAULT_TIMEZONE,
DEFAULT_TIMEZONE_CONFIGURED,
LOGGER, LOGGER,
TIME_PATTERN, TIME_PATTERN,
) )
@@ -241,6 +243,12 @@ _FIELDS: tuple[tuple[str, str, Any, Callable[[Any], Any]], ...] = (
_resolve_bool, _resolve_bool,
), ),
("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone), ("timezone", CONF_TIMEZONE, DEFAULT_TIMEZONE, _resolve_timezone),
(
"timezone_configured",
CONF_TIMEZONE_CONFIGURED,
DEFAULT_TIMEZONE_CONFIGURED,
_resolve_bool,
),
) )
@@ -271,6 +279,10 @@ class NestQuestSettings:
end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED end_of_day_report_enabled: bool = DEFAULT_AUTOMATION_ENABLED
celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED celebration_enabled: bool = DEFAULT_AUTOMATION_ENABLED
timezone: str = DEFAULT_TIMEZONE timezone: str = DEFAULT_TIMEZONE
#: ``True`` once the admin explicitly chose :attr:`timezone` (even
#: the empty host-local value); ``False`` on a fresh install, where
#: the admin UI may auto-set the browser's zone.
timezone_configured: bool = DEFAULT_TIMEZONE_CONFIGURED
def __post_init__(self) -> None: def __post_init__(self) -> None:
# Validate STRICTLY at construction so a hand-built instance # Validate STRICTLY at construction so a hand-built instance
@@ -303,6 +315,7 @@ class NestQuestSettings:
"afternoon_reminder_enabled", "afternoon_reminder_enabled",
"end_of_day_report_enabled", "end_of_day_report_enabled",
"celebration_enabled", "celebration_enabled",
"timezone_configured",
): ):
value = getattr(self, name) value = getattr(self, name)
if not isinstance(value, bool): if not isinstance(value, bool):
@@ -18,7 +18,7 @@ API client; the two are deliberately NOT unified here, and neither
side reads the other's values. side reads the other's values.
STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON
object holding exactly the ELEVEN settings fields, keyed by field name object holding exactly the TWELVE settings fields, keyed by field name
(which equals the ``CONF_*`` option key for every field), complete (which equals the ``CONF_*`` option key for every field), complete
after the first update: after the first update:
@@ -33,7 +33,11 @@ after the first update:
booleans, booleans,
- ``timezone`` — ``""`` (the API host's local time) or an IANA time - ``timezone`` — ``""`` (the API host's local time) or an IANA time
zone name such as ``America/New_York``. A document stored before zone name such as ``America/New_York``. A document stored before
this field existed simply lacks it and resolves to ``""``. this field existed simply lacks it and resolves to ``""``,
- ``timezone_configured`` — a real boolean: ``True`` once the admin
explicitly chose ``timezone`` (including the empty host-local value),
so the admin UI never auto-sets it again. A document stored before
this field existed lacks it and resolves to ``False``.
Only values whose core validators ACCEPTED are ever written, so a Only values whose core validators ACCEPTED are ever written, so a
stored document always round-trips through the core's option builders. stored document always round-trips through the core's option builders.
@@ -175,7 +179,7 @@ async def update_settings(
update can never leave a half-applied document behind. update can never leave a half-applied document behind.
The validated changes are merged onto the current effective The validated changes are merged onto the current effective
settings and the merged (complete, eleven-field) document is persisted settings and the merged (complete, twelve-field) document is persisted
in one atomic upsert; the updated settings are returned. The whole in one atomic upsert; the updated settings are returned. The whole
read → merge → write span runs inside the per-database settings read → merge → write span runs inside the per-database settings
lock, so concurrent updates queue instead of interleaving. lock, so concurrent updates queue instead of interleaving.
+41 -4
View File
@@ -13,7 +13,7 @@ proven against the DATABASE the routes wrote, not a parallel copy.
Proven per the done-condition, everything read back through the API: Proven per the done-condition, everything read back through the API:
- ``GET /api/v1/admin/settings`` returns the current effective settings - ``GET /api/v1/admin/settings`` returns the current effective settings
— the eleven documented fields at their defaults on a fresh database. — the twelve documented fields at their defaults on a fresh database.
- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the - ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the
NEXT read reflects them; a later partial update merges onto the NEXT read reflects them; a later partial update merges onto the
current effective settings (omitted fields keep their stored value), current effective settings (omitted fields keep their stored value),
@@ -60,7 +60,7 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
SETTINGS_META_KEY = "settings" SETTINGS_META_KEY = "settings"
#: The all-defaults effective settings a fresh database reports — the #: The all-defaults effective settings a fresh database reports — the
#: documented eleven fields, asserted literally so the payload contract is #: documented twelve fields, asserted literally so the payload contract is
#: pinned here, independent of the core constants. #: pinned here, independent of the core constants.
DEFAULT_SETTINGS = { DEFAULT_SETTINGS = {
"horizon_days": 14, "horizon_days": 14,
@@ -74,6 +74,7 @@ DEFAULT_SETTINGS = {
"end_of_day_report_enabled": True, "end_of_day_report_enabled": True,
"celebration_enabled": True, "celebration_enabled": True,
"timezone": "", "timezone": "",
"timezone_configured": False,
} }
@@ -104,7 +105,7 @@ async def settings_client(temp_db_path: str) -> SimpleNamespace:
async def test_get_settings_returns_defaults_on_a_fresh_database( async def test_get_settings_returns_defaults_on_a_fresh_database(
settings_client: SimpleNamespace, settings_client: SimpleNamespace,
) -> None: ) -> None:
"""GET returns the eleven documented fields at their defaults.""" """GET returns the twelve documented fields at their defaults."""
response = await settings_client.client.get( response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers() "/api/v1/admin/settings", headers=_admin_headers()
) )
@@ -140,7 +141,7 @@ async def test_update_persists_and_the_next_read_reflects_it(
assert reread.status_code == 200 assert reread.status_code == 200
assert reread.json() == expected assert reread.json() == expected
# ...and the DATABASE holds the merged document: the complete eleven # ...and the DATABASE holds the merged document: the complete twelve
# fields, keyed by field name, under the well-known meta_state key. # fields, keyed by field name, under the well-known meta_state key.
raw = await _dao_meta.MetaStateDao(settings_client.database).get( raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY SETTINGS_META_KEY
@@ -222,6 +223,40 @@ async def test_null_resets_a_field_to_its_default(
assert reread.json()["horizon_days"] == 14 assert reread.json()["horizon_days"] == 14
async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
settings_client: SimpleNamespace,
) -> None:
"""An explicit empty (host-local) zone persists as configured.
The admin clearing the zone PATCHes ``timezone: ""`` together with
``timezone_configured: true``; the NEXT read — a reopened settings
screen — sees the marker, so the empty value is a persisted choice
rather than a fresh install to auto-set.
"""
client = settings_client.client
fresh = await client.get("/api/v1/admin/settings", headers=_admin_headers())
assert fresh.json()["timezone_configured"] is False
patched = await client.patch(
"/api/v1/admin/settings",
json={"timezone": "", "timezone_configured": True},
headers=_admin_headers(),
)
assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone_configured": True}
assert patched.json() == expected
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == expected
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is not None
assert json.loads(raw)["timezone_configured"] is True
# --- validation: rejected values never change the stored settings ------- # --- validation: rejected values never change the stored settings -------
@@ -238,6 +273,8 @@ async def test_null_resets_a_field_to_its_default(
({"morning_summary_enabled": "yes"}, "morning_summary_enabled"), ({"morning_summary_enabled": "yes"}, "morning_summary_enabled"),
({"celebration_enabled": 1}, "celebration_enabled"), ({"celebration_enabled": 1}, "celebration_enabled"),
({"notify_target": 5}, "notify_target"), ({"notify_target": 5}, "notify_target"),
({"timezone_configured": "true"}, "timezone_configured"),
({"timezone_configured": 1}, "timezone_configured"),
], ],
) )
async def test_invalid_value_is_rejected_and_stored_settings_unchanged( async def test_invalid_value_is_rejected_and_stored_settings_unchanged(
+19
View File
@@ -236,6 +236,25 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
) )
def test_settings_timezone_configured_defaults_false_and_is_strict() -> None:
assert _settings.NestQuestSettings().timezone_configured is False
assert _settings.NestQuestSettings.from_options({}).timezone_configured is False
assert (
_settings.NestQuestSettings.from_options(
{"timezone_configured": True}
).timezone_configured
is True
)
for value in ("true", 1, None):
with pytest.raises(ValueError, match="timezone_configured"):
_settings.NestQuestSettings(timezone_configured=value)
if value is not None:
with pytest.raises(ValueError):
_settings.NestQuestSettings.from_options(
{"timezone_configured": value}
)
# --- the missed-sweep scheduler ---------------------------------------------- # --- the missed-sweep scheduler ----------------------------------------------