Publish the API's effective timezone and tolerate an older snapshot payload

TZ-002: with no zone stored the API reads its clock in the host's local
time, but the panel fell back to Home Assistant's zone and the admin app
to the browser's, so dates could disagree near midnight. The API now
names the zone it actually uses (api/host_zone.py: the stored zone, else
the host's own IANA zone from TZ, /etc/localtime or /etc/timezone) and
publishes it as the panel snapshot's `timezone` and the admin settings'
read-only `effective_timezone`. Both cards and the admin app format in
that one zone; Home Assistant's or the browser's zone applies only when
none is published.

TZ-003: snapshot_from_api_payload reads `timezone` with a "" default, so
an integration upgraded ahead of its API keeps refreshing against the
pre-change payload.

Task 7e173881-fb99-426f-a2f0-2678439ffc39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 09:50:25 -04:00
co-authored by Claude Opus 5.5
parent cf170214a6
commit 474c84bf35
26 changed files with 368 additions and 101 deletions
+28 -8
View File
@@ -41,6 +41,7 @@ from types import SimpleNamespace
import pytest
from api import host_zone
from tests.admin_jwt_harness import (
ADMIN_KEY,
KID,
@@ -60,9 +61,14 @@ _dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: independent of the core constant.
SETTINGS_META_KEY = "settings"
#: The API host's own zone, pinned for every test (:func:`settings_client`):
#: the ``effective_timezone`` while no zone is stored.
API_HOST_ZONE = "Australia/Perth"
#: The all-defaults effective settings a fresh database reports — the
#: documented eleven fields, asserted literally so the payload contract is
#: pinned here, independent of the core constants.
#: documented eleven fields plus the read-only ``effective_timezone``,
#: asserted literally so the payload contract is pinned here, independent
#: of the core constants.
DEFAULT_SETTINGS = {
"horizon_days": 14,
"day_rollover_time": "00:00",
@@ -75,22 +81,34 @@ DEFAULT_SETTINGS = {
"end_of_day_report_enabled": True,
"celebration_enabled": True,
"timezone": "",
"effective_timezone": API_HOST_ZONE,
}
def _with_zone(zone: str) -> dict:
"""The default settings with ``zone`` stored, as the API reports them."""
return {
**DEFAULT_SETTINGS,
"timezone": zone,
"effective_timezone": zone or API_HOST_ZONE,
}
def _admin_headers() -> dict[str, str]:
"""The Authorization header of a valid nestquest-admins JWT."""
return {"Authorization": f"Bearer {make_token()}"}
@pytest.fixture
async def settings_client(temp_db_path: str) -> SimpleNamespace:
async def settings_client(temp_db_path: str, monkeypatch) -> SimpleNamespace:
"""An admin-plane app client plus its live database.
The same runner the children tests use (stubbed JWKS, no network);
the fixture also exposes the app's ONE database connection so tests
can read (or corrupt) the stored settings document itself.
can read (or corrupt) the stored settings document itself. The API
host's zone is pinned to :data:`API_HOST_ZONE`.
"""
monkeypatch.setattr(host_zone, "host_timezone", lambda: API_HOST_ZONE)
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
@@ -147,7 +165,9 @@ async def test_update_persists_and_the_next_read_reflects_it(
SETTINGS_META_KEY
)
assert raw is not None
assert json.loads(raw) == expected
stored = dict(expected)
del stored["effective_timezone"] # derived on read, never stored
assert json.loads(raw) == stored
async def test_partial_update_merges_onto_the_current_settings(
@@ -254,7 +274,7 @@ async def test_manual_timezone_patch_persists_and_is_returned(
headers=_admin_headers(),
)
assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone": zone}
expected = _with_zone(zone)
assert patched.json() == expected
reread = await client.get(
@@ -285,7 +305,7 @@ async def test_timezone_alone_sticks_on_a_fresh_database(
reread = await client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert reread.json() == {**DEFAULT_SETTINGS, "timezone": zone}
assert reread.json() == _with_zone(zone)
async def test_timezone_configured_is_an_unknown_field(
@@ -319,7 +339,7 @@ async def test_stored_legacy_timezone_configured_key_is_ignored(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {**DEFAULT_SETTINGS, "timezone": "America/Chicago"}
assert response.json() == _with_zone("America/Chicago")
# --- validation: rejected values never change the stored settings -------
+7 -4
View File
@@ -37,7 +37,7 @@ from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api import host_zone, routes_admin, routes_panel
from api.nestquest_core import core_snapshot
from tests.admin_jwt_harness import (
ADMIN_KEY,
@@ -101,6 +101,8 @@ async def snapshot_household(
monkeypatch.setattr(routes_admin, "_local_now", _admin_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
# The API host runs UTC, the zone of the pinned clock.
monkeypatch.setattr(host_zone, "host_timezone", lambda: "UTC")
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client:
yield SimpleNamespace(
@@ -166,8 +168,9 @@ async def test_admin_jwt_returns_documented_snapshot(
Every field is asserted as an explicit literal, with ONE clock
read for the request. With no missed view in the real snapshot,
the admin body equals the panel body for the same household — the
panel body additionally carries the stored ``timezone`` its cards
format the visible date and clock in (unset here).
panel body additionally carries the effective ``timezone`` its cards
format the visible date and clock in (none stored here: the UTC
host's own zone).
"""
client = snapshot_household.client
seed = snapshot_household.seed
@@ -243,7 +246,7 @@ async def test_admin_jwt_returns_documented_snapshot(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
assert panel.json() == {**body, "timezone": ""}
assert panel.json() == {**body, "timezone": "UTC"}
async def test_admin_keeps_missed_instance_panel_omits_it(
+71
View File
@@ -0,0 +1,71 @@
"""The API host's zone and the effective application timezone (task 7e173881).
With no zone stored the API reads its clock in the host's local time, so
:func:`api.host_zone.effective_timezone` names that host zone — resolved
the way the C library resolves local time — for the panel and admin app
to format in. Every host configuration here is a temporary file tree.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from api import host_zone
def _host(tmp_path: Path, *, link: str | None = None, name: str | None = None):
"""A fake ``/etc``: ``localtime`` linked into a zoneinfo tree at
``link``, and/or an ``/etc/timezone`` file holding ``name``."""
localtime = tmp_path / "etc" / "localtime"
timezone_file = tmp_path / "etc" / "timezone"
localtime.parent.mkdir(parents=True)
if link is not None:
target = tmp_path / "usr" / "share" / "zoneinfo" / link
target.parent.mkdir(parents=True)
target.write_bytes(b"TZif")
localtime.symlink_to(target)
if name is not None:
timezone_file.write_text(f"{name}\n", encoding="utf-8")
return {"timezone_file": timezone_file, "localtime": localtime}
def test_tz_environment_variable_wins(tmp_path: Path) -> None:
paths = _host(tmp_path, link="Europe/Paris")
assert host_zone.host_timezone({"TZ": "Asia/Kolkata"}, **paths) == "Asia/Kolkata"
assert host_zone.host_timezone({"TZ": ":Asia/Kolkata"}, **paths) == "Asia/Kolkata"
assert host_zone.host_timezone({"TZ": ""}, **paths) == "UTC"
def test_localtime_link_names_the_zone(tmp_path: Path) -> None:
paths = _host(tmp_path, link="America/Argentina/Buenos_Aires")
assert host_zone.host_timezone({}, **paths) == "America/Argentina/Buenos_Aires"
def test_etc_timezone_names_a_copied_localtime(tmp_path: Path) -> None:
paths = _host(tmp_path, name="Pacific/Auckland")
paths["localtime"].write_bytes(b"TZif")
assert host_zone.host_timezone({}, **paths) == "Pacific/Auckland"
def test_unconfigured_host_runs_utc(tmp_path: Path) -> None:
assert host_zone.host_timezone({}, **_host(tmp_path)) == "UTC"
@pytest.mark.parametrize("tz", ["EST+5", "Not/AZone", ":/etc/localtime"])
def test_an_unnamed_host_zone_is_empty(tmp_path: Path, tz: str) -> None:
assert host_zone.host_timezone({"TZ": tz}, **_host(tmp_path)) == ""
def test_unnamed_copied_localtime_is_empty(tmp_path: Path) -> None:
paths = _host(tmp_path)
paths["localtime"].write_bytes(b"TZif")
assert host_zone.host_timezone({}, **paths) == ""
def test_effective_timezone_is_the_stored_zone_else_the_host_zone(
monkeypatch,
) -> None:
monkeypatch.setattr(host_zone, "host_timezone", lambda: "Europe/Dublin")
assert host_zone.effective_timezone("America/Chicago") == "America/Chicago"
assert host_zone.effective_timezone("") == "Europe/Dublin"
+38 -15
View File
@@ -32,7 +32,7 @@ from zoneinfo import ZoneInfo
import pytest
from api import routes_admin, routes_panel
from api import host_zone, routes_admin, routes_panel
from api import scheduler as scheduler_module
from api.database import _executor
from api.scheduler import MissedSweepScheduler, _delay_seconds
@@ -632,22 +632,45 @@ async def test_admin_saved_zone_is_authoritative_across_surfaces(
assert delays[1] == pytest.approx(299)
async def test_panel_snapshot_publishes_the_stored_zone(
household: SimpleNamespace,
async def test_empty_zone_publishes_the_api_host_zone_to_panel_and_admin(
household: SimpleNamespace, monkeypatch
) -> None:
"""The panel snapshot carries the stored application zone verbatim so
the cards format their visible date and clock in it — ``""`` until the
admin saves one, then exactly the saved zone."""
async def panel_zone() -> str:
response = await household.client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert response.status_code == 200
return response.json()["timezone"]
"""With no zone stored, the API reads the host's local time — and the
panel snapshot and the admin settings both publish that host's own
zone, so the cards and the admin app format in the API's zone, never
Home Assistant's or the browser's.
assert await panel_zone() == ""
await _set_zone(household, "Pacific/Auckland")
assert await panel_zone() == "Pacific/Auckland"
The host runs Pacific/Auckland: 12:30 UTC on the 26th is already
00:30 on the 27th there, so the API's household day is the 27th while
a UTC Home Assistant or browser would still read the 26th. Once the
admin saves a zone, both publish exactly that zone.
"""
host = "Pacific/Auckland"
monkeypatch.setattr(host_zone, "host_timezone", lambda: host)
household.clock["now"] = _utc(12, 30).astimezone(ZoneInfo(host))
panel = await household.client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
assert panel.status_code == 200
settings = await _admin_settings(household)
assert settings["timezone"] == ""
assert panel.json()["timezone"] == settings["effective_timezone"] == host
# The published zone reproduces the API's own household day.
assert panel.json()["today_iso"] == "2026-09-27"
assert (
_utc(12, 30).astimezone(ZoneInfo(host)).date().isoformat()
== panel.json()["today_iso"]
)
await _set_zone(household, "America/Chicago")
panel = await household.client.get(
"/api/v1/panel/snapshot", headers=_panel_headers()
)
settings = await _admin_settings(household)
assert settings["timezone"] == "America/Chicago"
assert panel.json()["timezone"] == settings["effective_timezone"]
assert settings["effective_timezone"] == "America/Chicago"
# --- settings writes are serialised with planning and sweeping (TZ-003) ------
+24
View File
@@ -804,3 +804,27 @@ async def test_snapshot_from_api_payload_empty_household() -> None:
assert snapshot.children == ()
assert snapshot.cycle_day == 0
assert snapshot.today_iso == "2026-09-23"
#: The panel snapshot exactly as an API from before the ``timezone`` field
#: serves it: an integration upgraded ahead of its API still reads this.
LEGACY_SNAPSHOT = {
key: value for key, value in FIXTURE_SNAPSHOT.items() if key != "timezone"
}
async def test_coordinator_reads_a_legacy_payload_without_timezone(
hass, make_entry
) -> None:
"""An older API omits ``timezone``: every refresh still builds the
snapshot (the cards then fall back to Home Assistant's zone)."""
assert "timezone" not in LEGACY_SNAPSHOT
client = StubSnapshotClient(LEGACY_SNAPSHOT)
_entry, coordinator = await _wire_entry(hass, make_entry, client)
assert coordinator.last_update_success is True
snapshot = await coordinator._async_update_data()
assert snapshot.timezone == ""
assert snapshot.cycle_day == 3
assert [child.child_name for child in snapshot.children] == ["Ada", "Bo"]
assert snapshot_from_api_payload(LEGACY_SNAPSHOT).timezone == ""
+5 -3
View File
@@ -1245,7 +1245,8 @@ def test_quest_log_overdue_follows_the_api_snapshot_not_the_panel_clock() -> Non
def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None:
"""The stored application timezone is the single source of local-time
truth for the panel's visible date and clock too: the household rollup
publishes it (``timezone``) and both cards format in it — the party
publishes it (``timezone`` — the API's effective zone) and both cards
format in it — the party
board's kicker and dock date and clock, the quest log's header date —
even though Home Assistant's zone and the panel browser's zone are both
UTC. At 2026-09-29T23:30Z it is still Tuesday 11:30 PM in UTC but
@@ -1292,8 +1293,9 @@ def test_panel_date_and_clock_follow_the_stored_application_timezone() -> None:
assert sub is not None, log
assert sub.group(1) == "Wednesday, Sep 30"
# With no stored zone the documented fallback applies: Home
# Assistant's zone (UTC here) formats the same instant.
# With no zone stored the API publishes its host's own zone, so the
# rollup always carries one; only a rollup without it (an API older
# than the field) falls back to Home Assistant's zone (UTC here).
del states["sensor.nestquest_household_quests_due_today"]["attributes"][
"timezone"
]