Remove every automatic timezone-setting mechanism (task 7f685179)

The application timezone is now only ever set by an admin saving it:

- API: drop adopt_reported_timezone; the panel household clock reads the
  stored settings only and ignores any timezone request header.
- Integration: stop reporting Home Assistant's time_zone in an
  X-NestQuest-Timezone header (snapshot, completion and SSE requests).
- Admin app: remove the browser-zone detection and the automatic PATCH
  on load; the timezone field stays a manual control.
- Remove the timezone_configured marker from the core settings, the
  settings store and the admin settings API. A stored document that
  still carries the key loads harmlessly (unknown keys are ignored).

Tests replace the adoption expectations with proofs that a panel
timezone header never changes the stored settings, a fresh install's
empty zone stays empty with no settings write, the admin settings carry
no timezone_configured, and a manual admin timezone PATCH persists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 07:00:06 -04:00
co-authored by Claude Opus 5.5
parent 6e25c9e42c
commit 5019b0cdc2
16 changed files with 282 additions and 546 deletions
-6
View File
@@ -19,12 +19,6 @@ 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. */
+25 -70
View File
@@ -23,7 +23,6 @@ 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(
@@ -508,7 +507,7 @@ describe("SettingsScreen — preferences", () => {
save(form); save(form);
await screen.findByText("Preferences saved."); await screen.findByText("Preferences saved.");
expect(writes()).toEqual([ expect(writes()).toEqual([
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London", timezone_configured: true } }, { method: "PATCH", path: SETTINGS_PATH, body: { timezone: "Europe/London" } },
]); ]);
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");
@@ -559,7 +558,7 @@ describe("SettingsScreen — preferences", () => {
}); });
}); });
describe("SettingsScreen — timezone auto-set", () => { describe("SettingsScreen — no timezone auto-set", () => {
let zoneSpy: ReturnType<typeof vi.spyOn> | null; let zoneSpy: ReturnType<typeof vi.spyOn> | null;
function mockBrowserZone(timeZone: string | undefined) { function mockBrowserZone(timeZone: string | undefined) {
@@ -575,8 +574,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)];
// A fresh install: no zone, and the admin never chose one. // A fresh install: no zone stored.
settings = { ...DEFAULT_SETTINGS, timezone: "", timezone_configured: false }; settings = { ...DEFAULT_SETTINGS, timezone: "" };
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));
@@ -595,25 +594,15 @@ describe("SettingsScreen — timezone auto-set", () => {
return screen.findByRole("form", { name: "Preferences" }); return screen.findByRole("form", { name: "Preferences" });
} }
it("an unset timezone is set once, on load, to the browser's zone and shown", async () => { it("an empty timezone stays empty on load and nothing is written", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe("America/New_York"); expect(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([ expect(writes()).toEqual([]);
{ method: "PATCH", path: SETTINGS_PATH, body: { timezone: "America/New_York", timezone_configured: true } }, expect(settings.timezone).toBe("");
]);
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.
fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
expect(writes()).toHaveLength(2);
expect(writes()[1].body).toEqual({ horizon_days: 21 });
}); });
it("StrictMode's doubled effects still auto-set exactly once", async () => { it("StrictMode's doubled effects write nothing either", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
render( render(
<StrictMode> <StrictMode>
@@ -621,70 +610,36 @@ describe("SettingsScreen — timezone auto-set", () => {
</StrictMode>, </StrictMode>,
); );
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(writes()).toEqual([
{ 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, even after a reopen", async () => {
mockBrowserZone("America/New_York");
const form = await renderForm();
fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
await screen.findByText("Preferences saved.");
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" });
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(field(form, "Timezone").value).toBe("");
expect(writes()).toEqual([]); expect(writes()).toEqual([]);
}); });
it("a set timezone is shown and never overwritten by the browser zone", async () => { it("a stored timezone is shown as stored, never replaced by the browser zone", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin", timezone_configured: false }; settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
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([]);
}); });
it.each([ it("saving another field sends only that field, never a timezone", async () => {
["nothing", undefined], mockBrowserZone("America/New_York");
["an empty zone", ""],
["an unknown zone", "Etc/Unknown"],
["a non-IANA zone", "not a zone"],
])("detection returning %s leaves the field empty and sends nothing", async (_case, zone) => {
mockBrowserZone(zone);
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe(""); fireEvent.change(field(form, "Planning horizon (days)"), { target: { value: "21" } });
expect(screen.queryByTestId("preferences-message")).toBeNull(); fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
expect(writes()).toEqual([]); await screen.findByText("Preferences saved.");
expect(writes().map((w) => w.body)).toEqual([{ horizon_days: 21 }]);
expect(settings.timezone).toBe("");
}); });
it("a rejected auto-set shows why and leaves the field empty for manual entry", async () => { it("an admin clearing the timezone saves exactly the empty zone", async () => {
mockBrowserZone("America/New_York"); mockBrowserZone("America/New_York");
writeResponse = () => jsonResponse({ detail: "timezone is not known" }, 422); settings = { ...DEFAULT_SETTINGS, timezone: "Europe/Dublin" };
const form = await renderForm(); const form = await renderForm();
expect(field(form, "Timezone").value).toBe(""); fireEvent.change(field(form, "Timezone"), { target: { value: "" } });
expect(field(form, "Timezone").disabled).toBe(false); fireEvent.click(within(form).getByRole("button", { name: "Save preferences" }));
expect(screen.getByTestId("preferences-message").textContent).toBe( await screen.findByText("Preferences saved.");
"The timezone could not be set to America/New_York: timezone is not known", expect(writes().map((w) => w.body)).toEqual([{ timezone: "" }]);
); expect(field(screen.getByRole("form", { name: "Preferences" }), "Timezone").value).toBe("");
expect(writes()).toHaveLength(1);
}); });
}); });
+4 -44
View File
@@ -5,9 +5,8 @@
* 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. A timezone * timezone, notifications). Save patches only the changed fields. The
* the admin never chose is set once, on load, to the browser's own zone; * timezone is only ever set by the admin saving it here.
* 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.
*/ */
@@ -291,19 +290,6 @@ export const COMMON_TIMEZONES = [
"Pacific/Auckland", "Pacific/Auckland",
]; ];
/**
* The browser's own IANA zone — the location-derived default for an unset
* household timezone — or "" when it is missing or not a usable zone name.
*/
export function detectTimezone(): string {
try {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return typeof zone === "string" && zone !== "Etc/Unknown" && IANA_ZONE.test(zone) ? zone : "";
} catch {
return "";
}
}
/** The form's working copy: the number is text so it can be mid-edit. */ /** The form's working copy: the number is text so it can be mid-edit. */
type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string }; type PreferencesDraft = Omit<HouseholdSettings, "horizon_days"> & { horizon_days: string };
@@ -342,9 +328,6 @@ 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;
} }
@@ -574,8 +557,6 @@ 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 never-chosen-timezone auto-set fires at most once per screen.
const timezoneAutoSet = useRef(false);
const screenRef = useRef<HTMLDivElement>(null); const screenRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLHeadingElement>(null); const titleRef = useRef<HTMLHeadingElement>(null);
const addRef = useRef<HTMLButtonElement>(null); const addRef = useRef<HTMLButtonElement>(null);
@@ -620,29 +601,8 @@ export default function SettingsScreen({ onClose }: { onClose: () => void }) {
let cancelled = false; let cancelled = false;
setPreferences({ kind: "loading" }); setPreferences({ kind: "loading" });
fetchSettings() fetchSettings()
.then(async (settings) => { .then((settings) => {
if (cancelled) return; if (!cancelled) setPreferences({ kind: "ready", settings });
// Never chosen: adopt the browser's zone before the form shows, so
// nothing the admin enters can be overwritten by it. An empty zone the
// admin chose (timezone_configured) is left alone.
const unchosen = settings.timezone === "" && !settings.timezone_configured;
const detected = unchosen ? detectTimezone() : "";
if (!detected || timezoneAutoSet.current) {
setPreferences({ kind: "ready", settings });
return;
}
timezoneAutoSet.current = true;
try {
const saved = await updateSettings({ timezone: detected, timezone_configured: true });
if (!cancelled) setPreferences({ kind: "ready", settings: saved });
} catch (error) {
if (cancelled) return;
setPreferences({ kind: "ready", settings });
setPreferencesMessage({
kind: "error",
text: errorMessage(error, `The timezone could not be set to ${detected}`),
});
}
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (cancelled) return; if (cancelled) return;
+7 -12
View File
@@ -165,13 +165,12 @@ 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 twelve - ``GET /settings`` returns the current effective settings: all eleven
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, the ``timezone`` — the IANA household zone the toggles, and the ``timezone`` — the IANA household zone the
household-local dates derive from, empty meaning host local — and household-local dates derive from, empty meaning host local; only an
``timezone_configured``, whether the admin explicitly chose that admin's ``PATCH`` ever sets it) — the defaults on a fresh database.
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
@@ -906,7 +905,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 TWELVE documented fields, resolved (every absent stored field — all ELEVEN 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.
""" """
@@ -924,9 +923,6 @@ 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):
@@ -2183,7 +2179,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 twelve documented fields even if the core stays pinned to the eleven documented fields even if the core
dataclass later grows another one. dataclass later grows another one.
""" """
return AdminSettingsResponse( return AdminSettingsResponse(
@@ -2198,7 +2194,6 @@ 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,
) )
@@ -2296,7 +2291,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 twelve documented fields). """Return the current effective settings (all eleven 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
+11 -45
View File
@@ -9,9 +9,8 @@ this router inherits the check).
The snapshot route is a thin adapter over the bundled core: it performs The snapshot route is a thin adapter over the bundled core: it performs
NO business logic. It reads the live database off ``app.state.db``, NO business logic. It reads the live database off ``app.state.db``,
resolves ONE timezone-aware ``now`` (the host clock re-expressed in the resolves ONE timezone-aware ``now`` (the host clock re-expressed in the
household ``timezone`` — the API host may run UTC while the household household ``timezone`` the admin stored — the API host may run UTC
does not; until the admin chooses a zone it is the Home Assistant zone while the household does not), calls the pure
the integration reports in :data:`PANEL_TIMEZONE_HEADER`), calls the pure
:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's :func:`nestquest_core.snapshot.build_snapshot`, shapes each child's
instances through :func:`nestquest_core.snapshot.instance_payload` with instances through :func:`nestquest_core.snapshot.instance_payload` with
``include_missed=False`` (D-009: missed instances are OMITTED from the ``include_missed=False`` (D-009: missed instances are OMITTED from the
@@ -54,7 +53,6 @@ from __future__ import annotations
import datetime import datetime
import importlib import importlib
import logging
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
@@ -64,7 +62,6 @@ from api.database import DatabaseState
from api.dependencies import require_panel_token from api.dependencies import require_panel_token
from api.nestquest_core import ( from api.nestquest_core import (
core_completion, core_completion,
core_const,
core_events, core_events,
core_settings_store, core_settings_store,
core_snapshot, core_snapshot,
@@ -72,12 +69,6 @@ from api.nestquest_core import (
from api import transitions as api_transitions from api import transitions as api_transitions
from api.sse import transition_event_stream from api.sse import transition_event_stream
LOGGER = logging.getLogger(__name__)
#: The request header the integration reports Home Assistant's
#: configured IANA ``time_zone`` in.
PANEL_TIMEZONE_HEADER = core_const.PANEL_TIMEZONE_HEADER
#: The instance DAO, imported on the app's core copy for the #: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route. #: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances") _dao_instances = importlib.import_module("nestquest_core.dao_instances")
@@ -188,42 +179,17 @@ def _local_now() -> datetime.datetime:
async def _household_clock( async def _household_clock(
database: object, request: Request database: object,
) -> tuple[object, datetime.datetime]: ) -> tuple[object, datetime.datetime]:
"""Return the settings and ONE household-local clock read. """Return the stored settings and ONE household-local clock read.
The API host may run UTC while the household does not, so the one The API host may run UTC while the household does not, so the one
:func:`_local_now` read is re-expressed in the settings' ``timezone`` :func:`_local_now` read is re-expressed in the stored settings'
(:meth:`NestQuestSettings.household_now`). The integration reports ``timezone`` (:meth:`NestQuestSettings.household_now`). The zone
Home Assistant's ``time_zone`` in :data:`PANEL_TIMEZONE_HEADER`; comes from the stored settings ONLY — nothing the request carries
until the admin chooses a zone it is adopted and persisted can change it.
(:func:`nestquest_core.settings_store.adopt_reported_timezone`), so
the admin plane follows it too, and the already-sleeping missed-sweep
scheduler is told (:meth:`MissedSweepScheduler.settings_changed`) so
it re-plans for the household rollover — under the scheduler's
``settings_lock``, so the adoption never lands between its settings
check and its sweep. An invalid
reported zone is logged and ignored — the panel keeps serving on
the stored settings.
""" """
reported = request.headers.get(PANEL_TIMEZONE_HEADER, "").strip() settings = await core_settings_store.load_settings(database)
if reported:
scheduler = request.app.state.sweep_scheduler
try:
async with scheduler.settings_lock:
settings = await core_settings_store.adopt_reported_timezone(
database, reported
)
scheduler.settings_changed(settings)
except ValueError:
LOGGER.warning(
"Ignoring invalid %s header value %r",
PANEL_TIMEZONE_HEADER,
reported,
)
settings = await core_settings_store.load_settings(database)
else:
settings = await core_settings_store.load_settings(database)
return settings, settings.household_now(_local_now()) return settings, settings.household_now(_local_now())
@@ -245,7 +211,7 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
:class:`PanelSnapshotResponse`. :class:`PanelSnapshotResponse`.
""" """
state: DatabaseState = request.app.state.db state: DatabaseState = request.app.state.db
settings, now = await _household_clock(state.database, request) settings, now = await _household_clock(state.database)
snapshot = await core_snapshot.build_snapshot( snapshot = await core_snapshot.build_snapshot(
state.database, settings, now state.database, settings, now
) )
@@ -332,7 +298,7 @@ async def panel_complete_instance(
detail="Quest instance not found for this child", detail="Quest instance not found for this child",
) )
_settings, now = await _household_clock(database, request) _settings, now = await _household_clock(database)
try: try:
result = await core_completion.complete_instance( result = await core_completion.complete_instance(
database, database,
+10 -10
View File
@@ -13,7 +13,7 @@ local date when unset) and publishes each returned
``nestquest_quest_missed`` event on the app's ONE transition publisher ``nestquest_quest_missed`` event on the app's ONE transition publisher
(the same SSE stream every route publishes through). (the same SSE stream every route publishes through).
A household zone adopted from Home Assistant while the loop is asleep A household zone changed while the loop is asleep
must not leave it waiting for the OLD zone's rollover (a UTC host would must not leave it waiting for the OLD zone's rollover (a UTC host would
otherwise sweep at UTC midnight — 20:00 in New York — marking quests otherwise sweep at UTC midnight — 20:00 in New York — marking quests
missed hours early). :meth:`MissedSweepScheduler.settings_changed` missed hours early). :meth:`MissedSweepScheduler.settings_changed`
@@ -21,17 +21,17 @@ wakes the sleeping loop to re-plan whenever the zone or rollover time
it planned with has moved, and the loop re-reads the settings before it planned with has moved, and the loop re-reads the settings before
sweeping and re-plans instead if they no longer match its plan. sweeping and re-plans instead if they no longer match its plan.
Guarantee: no sweep runs for a plan a committed adoption has replaced. Guarantee: no sweep runs for a plan a committed settings write has
An adopting writer holds :attr:`MissedSweepScheduler.settings_lock` replaced. A settings writer holds :attr:`MissedSweepScheduler.settings_lock`
across its write AND its :meth:`~MissedSweepScheduler.settings_changed` across its write AND its :meth:`~MissedSweepScheduler.settings_changed`
call; the loop holds the same lock across its plan read (read, record call; the loop holds the same lock across its plan read (read, record
the plan, clear the wake flag) and across the pre-sweep re-read plus the plan, clear the wake flag) and across the pre-sweep re-read plus
the sweep itself. So an adoption commits either before a plan read the sweep itself. So a write commits either before a plan read
(which then sees it), after one (``settings_changed`` compares against (which then sees it), after one (``settings_changed`` compares against
that exact plan and wakes the wait), or after the sweep — never between that exact plan and wakes the wait), or after the sweep — never between
a read and what the loop does with it. The lock is never held across a read and what the loop does with it. The lock is never held across
the wait, and the loop takes nothing else before it, so a waiting the wait, and the loop takes nothing else before it, so a waiting
panel request cannot deadlock: it is delayed at most by one settings writer cannot deadlock: it is delayed at most by one settings
read or one sweep. read or one sweep.
All sweep policy lives in the core (the watermark, the All sweep policy lives in the core (the watermark, the
@@ -150,7 +150,7 @@ class MissedSweepScheduler:
self._planned: tuple[str, str] | None = None self._planned: tuple[str, str] | None = None
#: Set by :meth:`settings_changed` to cut the current wait short. #: Set by :meth:`settings_changed` to cut the current wait short.
self._wake = asyncio.Event() self._wake = asyncio.Event()
#: Serialises settings adoption with planning and sweeping (see #: Serialises settings writes with planning and sweeping (see
#: the module docstring's guarantee). #: the module docstring's guarantee).
self.settings_lock = asyncio.Lock() self.settings_lock = asyncio.Lock()
@@ -174,8 +174,8 @@ class MissedSweepScheduler:
def settings_changed(self, settings: object) -> None: def settings_changed(self, settings: object) -> None:
"""Re-plan the pending sweep if ``settings`` moved its rollover. """Re-plan the pending sweep if ``settings`` moved its rollover.
Called with freshly stored settings (e.g. a zone adopted from Called with freshly stored settings (e.g. an admin's zone
Home Assistant), with :attr:`settings_lock` held across the write change), with :attr:`settings_lock` held across the write
and this call so the comparison is against the loop's current and this call so the comparison is against the loop's current
plan, never a read still in flight. A differing ``timezone`` or plan, never a read still in flight. A differing ``timezone`` or
``day_rollover_time`` wakes the sleeping loop, which reloads the ``day_rollover_time`` wakes the sleeping loop, which reloads the
@@ -194,7 +194,7 @@ class MissedSweepScheduler:
"""Schedule → sweep → repeat, forever (until cancelled).""" """Schedule → sweep → repeat, forever (until cancelled)."""
while True: while True:
try: try:
# Under the lock no adoption can commit between this read # Under the lock no settings write can commit between this read
# and the recorded plan, so any later one wakes the wait. # and the recorded plan, so any later one wakes the wait.
async with self.settings_lock: async with self.settings_lock:
self._wake.clear() self._wake.clear()
@@ -233,7 +233,7 @@ class MissedSweepScheduler:
# Re-read before sweeping: a zone or rollover change the # Re-read before sweeping: a zone or rollover change the
# wake-up missed means this is not the household's # wake-up missed means this is not the household's
# rollover any more, so re-plan instead of sweeping. The # rollover any more, so re-plan instead of sweeping. The
# lock keeps an adoption from committing between this # lock keeps a settings write from committing between this
# check and the sweep. # check and the sweep.
async with self.settings_lock: async with self.settings_lock:
settings = await core_settings_store.load_settings( settings = await core_settings_store.load_settings(
+4 -21
View File
@@ -6,9 +6,6 @@ speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's
household snapshot) and ``POST /api/v1/panel/instances/{id}/complete`` household snapshot) and ``POST /api/v1/panel/instances/{id}/complete``
(completion as the tapped child profile) — authenticated with the (completion as the tapped child profile) — authenticated with the
static panel service token as a Bearer credential on every request. static panel service token as a Bearer credential on every request.
Every request also reports Home Assistant's configured ``time_zone`` in
the ``X-NestQuest-Timezone`` header, so the API (which may run UTC)
reads the household's clock until the admin chooses a zone there.
Transport-injected by construction: the constructor takes the base URL, Transport-injected by construction: the constructor takes the base URL,
the panel token, and the HTTP session to use. The session speaks the the panel token, and the HTTP session to use. The session speaks the
@@ -57,7 +54,6 @@ from .const import (
CONF_PANEL_TOKEN, CONF_PANEL_TOKEN,
DEFAULT_API_BASE_URL, DEFAULT_API_BASE_URL,
DEFAULT_PANEL_TOKEN, DEFAULT_PANEL_TOKEN,
PANEL_TIMEZONE_HEADER,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -160,8 +156,7 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
"""Build the production client for a config entry. """Build the production client for a config entry.
Reads the configured base URL and panel token off the entry Reads the configured base URL and panel token off the entry
(:func:`resolve_api_config`), reports ``hass.config.time_zone`` as (:func:`resolve_api_config`) and hands the client Home Assistant's
the household zone, and hands the client Home Assistant's
SHARED aiohttp session (``homeassistant.helpers.aiohttp_client SHARED aiohttp session (``homeassistant.helpers.aiohttp_client
.async_get_clientsession``) so panel calls ride HA's one connection .async_get_clientsession``) so panel calls ride HA's one connection
pool. The helper import is deferred into this function on purpose: pool. The helper import is deferred into this function on purpose:
@@ -173,10 +168,7 @@ def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient:
base_url, panel_token = resolve_api_config(entry) base_url, panel_token = resolve_api_config(entry)
return NestQuestApiClient( return NestQuestApiClient(
base_url, base_url, panel_token, async_get_clientsession(hass)
panel_token,
async_get_clientsession(hass),
household_timezone=hass.config.time_zone,
) )
@@ -197,14 +189,9 @@ class NestQuestApiClient:
session: aiohttp.ClientSession, session: aiohttp.ClientSession,
*, *,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
household_timezone: str | None = None,
) -> None: ) -> None:
"""Store the connection settings; the session is injected. """Store the connection settings; the session is injected.
``household_timezone`` is Home Assistant's configured IANA
``time_zone``; when given it rides every request in the
``X-NestQuest-Timezone`` header.
``session`` is an aiohttp ``ClientSession`` in production and a ``session`` is an aiohttp ``ClientSession`` in production and a
stub transport in tests. ``base_url`` must be a full http(s) stub transport in tests. ``base_url`` must be a full http(s)
URL and ``panel_token`` must be non-empty — a client built URL and ``panel_token`` must be non-empty — a client built
@@ -229,14 +216,10 @@ class NestQuestApiClient:
self._panel_token = panel_token self._panel_token = panel_token
self._session = session self._session = session
self._timeout_seconds = float(timeout_seconds) self._timeout_seconds = float(timeout_seconds)
self._household_timezone = household_timezone
def _headers(self) -> dict[str, str]: def _headers(self) -> dict[str, str]:
"""The Bearer credential plus the reported household zone.""" """The Bearer credential every panel-plane request carries."""
headers = {"Authorization": f"Bearer {self._panel_token}"} return {"Authorization": f"Bearer {self._panel_token}"}
if self._household_timezone:
headers[PANEL_TIMEZONE_HEADER] = self._household_timezone
return headers
async def get_snapshot(self) -> dict[str, Any]: async def get_snapshot(self) -> dict[str, Any]:
"""Fetch and parse today's household snapshot. """Fetch and parse today's household snapshot.
+1 -4
View File
@@ -372,10 +372,7 @@ def coordinator_client_from_entry(
return _UnconfiguredApiClient() return _UnconfiguredApiClient()
if session_factory is not None: if session_factory is not None:
return NestQuestApiClient( return NestQuestApiClient(
base_url, base_url, panel_token, session_factory(hass)
panel_token,
session_factory(hass),
household_timezone=hass.config.time_zone,
) )
from custom_components.nestquest.api_client import client_from_entry from custom_components.nestquest.api_client import client_from_entry
+2 -15
View File
@@ -114,24 +114,11 @@ CONF_CELEBRATION_ENABLED = "celebration_enabled"
DEFAULT_AUTOMATION_ENABLED = True DEFAULT_AUTOMATION_ENABLED = True
# The household's IANA time zone (e.g. ``America/New_York``) the API # The household's IANA time zone (e.g. ``America/New_York``) the API
# service reads its clock in. The empty string is the API host's own # service reads its clock in. The empty string is the API host's own
# local time. While the admin has not chosen a zone # local time. Only an admin sets it (manually, from the admin app);
# (``timezone_configured`` is ``False``) the API adopts the zone the # nothing sets it automatically. The integration itself always uses
# integration reports from Home Assistant's configured ``time_zone``
# (:data:`PANEL_TIMEZONE_HEADER`), so the default is the household's
# zone without any manual setup. The integration itself always uses
# Home Assistant's ``time_zone`` and ignores this field. # Home Assistant's ``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 API follow Home Assistant's reported zone
# and the admin UI auto-set the browser's zone once while none is
# stored; ``True`` pins the stored value — including an explicitly-chosen
# empty ``timezone`` (the API host's local time) — so neither changes it.
CONF_TIMEZONE_CONFIGURED = "timezone_configured"
DEFAULT_TIMEZONE_CONFIGURED = False
# The panel-plane request header carrying Home Assistant's configured
# IANA ``time_zone`` from the integration to the API service.
PANEL_TIMEZONE_HEADER = "X-NestQuest-Timezone"
# 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,7 +49,6 @@ 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,
@@ -57,7 +56,6 @@ 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,
) )
@@ -243,12 +241,6 @@ _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,
),
) )
@@ -279,10 +271,6 @@ 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
@@ -315,7 +303,6 @@ 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):
@@ -33,12 +33,8 @@ 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 ``""``. Only an
- ``timezone_configured`` — a real boolean: ``True`` once the admin admin's explicit update ever writes it; nothing sets it automatically.
explicitly chose ``timezone`` (including the empty host-local value),
so neither Home Assistant's reported zone
(:func:`adopt_reported_timezone`) nor the admin UI changes 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.
@@ -180,15 +176,10 @@ 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, twelve-field) document is persisted settings and the merged (complete, eleven-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.
Supplying ``timezone`` without ``timezone_configured`` records the
zone as the admin's explicit choice (``timezone_configured`` is
persisted ``True`` in the same write); an explicitly supplied
``timezone_configured`` is kept exactly as given.
""" """
if not isinstance(changes, Mapping): if not isinstance(changes, Mapping):
raise ValueError( raise ValueError(
@@ -204,37 +195,11 @@ async def update_settings(
raise ValueError( raise ValueError(
f"unknown settings field(s): {listed}; known fields: {known}" f"unknown settings field(s): {listed}; known fields: {known}"
) )
if "timezone" in changes and "timezone_configured" not in changes:
# An admin-supplied zone is an explicit choice, so Home
# Assistant's reported zone must no longer replace it.
changes = {**changes, "timezone_configured": True}
async with _settings_lock(database): async with _settings_lock(database):
current = await load_settings(database) current = await load_settings(database)
return await _write_merged(database, current, changes) return await _write_merged(database, current, changes)
async def adopt_reported_timezone(
database: NestQuestDatabase, zone: str
) -> NestQuestSettings:
"""Adopt Home Assistant's reported ``zone`` unless the admin chose one.
While ``timezone_configured`` is ``False`` the stored ``timezone``
follows the zone the integration reports, so the API's clock is the
household's without any manual setup; ``timezone_configured`` stays
``False`` (the value is automatic, not an admin choice). Once the
admin has chosen — including an explicitly-chosen empty zone — the
stored settings are returned untouched. The check and the write
share the settings lock, so a concurrent admin choice is never
overwritten. An invalid ``zone`` raises a ``ValueError`` naming
``timezone`` and writes nothing.
"""
async with _settings_lock(database):
current = await load_settings(database)
if current.timezone_configured or current.timezone == zone:
return current
return await _write_merged(database, current, {"timezone": zone})
async def _write_merged( async def _write_merged(
database: NestQuestDatabase, database: NestQuestDatabase,
current: NestQuestSettings, current: NestQuestSettings,
+63 -18
View File
@@ -13,7 +13,8 @@ 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 twelve documented fields at their defaults on a fresh database. — the eleven documented fields at their defaults on a fresh database,
with no ``timezone_configured`` marker.
- ``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 +61,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 twelve fields, asserted literally so the payload contract is #: documented eleven 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,7 +75,6 @@ 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,
} }
@@ -105,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 twelve documented fields at their defaults.""" """GET returns the eleven 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()
) )
@@ -223,27 +223,38 @@ 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( async def test_default_settings_carry_no_timezone_configured_marker(
settings_client: SimpleNamespace, settings_client: SimpleNamespace,
) -> None: ) -> None:
"""An explicit empty (host-local) zone persists as configured. """The removed auto-set marker is absent from the default response."""
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert "timezone_configured" not in response.json()
assert response.json()["timezone"] == ""
The admin clearing the zone PATCHes ``timezone: ""`` together with
``timezone_configured: true``; the NEXT read — a reopened settings @pytest.mark.parametrize("zone", ["America/New_York", ""])
screen — sees the marker, so the empty value is a persisted choice async def test_manual_timezone_patch_persists_and_is_returned(
rather than a fresh install to auto-set. settings_client: SimpleNamespace, zone: str
""" ) -> None:
"""An admin's timezone PATCH is stored exactly as given and read back."""
client = settings_client.client client = settings_client.client
fresh = await client.get("/api/v1/admin/settings", headers=_admin_headers()) seeded = await client.patch(
assert fresh.json()["timezone_configured"] is False "/api/v1/admin/settings",
json={"timezone": "Europe/Dublin"},
headers=_admin_headers(),
)
assert seeded.status_code == 200
patched = await client.patch( patched = await client.patch(
"/api/v1/admin/settings", "/api/v1/admin/settings",
json={"timezone": "", "timezone_configured": True}, json={"timezone": zone},
headers=_admin_headers(), headers=_admin_headers(),
) )
assert patched.status_code == 200 assert patched.status_code == 200
expected = {**DEFAULT_SETTINGS, "timezone_configured": True} expected = {**DEFAULT_SETTINGS, "timezone": zone}
assert patched.json() == expected assert patched.json() == expected
reread = await client.get( reread = await client.get(
@@ -254,7 +265,43 @@ async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
SETTINGS_META_KEY SETTINGS_META_KEY
) )
assert raw is not None assert raw is not None
assert json.loads(raw)["timezone_configured"] is True stored = json.loads(raw)
assert stored["timezone"] == zone
assert "timezone_configured" not in stored
async def test_timezone_configured_is_an_unknown_field(
settings_client: SimpleNamespace,
) -> None:
"""The removed marker is rejected like any unknown field; nothing is written."""
response = await settings_client.client.patch(
"/api/v1/admin/settings",
json={"timezone_configured": True},
headers=_admin_headers(),
)
assert response.status_code == 422
assert "timezone_configured" in str(response.json()["detail"])
raw = await _dao_meta.MetaStateDao(settings_client.database).get(
SETTINGS_META_KEY
)
assert raw is None
async def test_stored_legacy_timezone_configured_key_is_ignored(
settings_client: SimpleNamespace,
) -> None:
"""A document written before the marker was removed still loads."""
await _dao_meta.MetaStateDao(settings_client.database).set(
SETTINGS_META_KEY,
json.dumps(
{"timezone": "America/Chicago", "timezone_configured": True}
),
)
response = await settings_client.client.get(
"/api/v1/admin/settings", headers=_admin_headers()
)
assert response.status_code == 200
assert response.json() == {**DEFAULT_SETTINGS, "timezone": "America/Chicago"}
# --- validation: rejected values never change the stored settings ------- # --- validation: rejected values never change the stored settings -------
@@ -273,8 +320,6 @@ async def test_timezone_configured_round_trips_with_an_explicit_empty_zone(
({"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(
+17 -22
View File
@@ -196,25 +196,27 @@ async def test_complete_instance_posts_body_and_headers() -> None:
assert call["headers"]["Authorization"] == f"Bearer {TOKEN}" assert call["headers"]["Authorization"] == f"Bearer {TOKEN}"
async def test_household_timezone_rides_every_request_header() -> None: async def test_requests_carry_only_the_bearer_header() -> None:
"""Home Assistant's zone is reported so the API reads the household """No timezone is reported to the API (task 7f685179): the Bearer
clock (task 6351b36d); it is sent only when the client has one.""" credential is the only header on every request."""
transport = StubTransport( transport = StubTransport(
StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)), StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)),
StubResponse(200, '{"status": "done"}'), StubResponse(200, '{"status": "done"}'),
) )
client = NestQuestApiClient( client = make_client(transport)
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
)
await client.get_snapshot() await client.get_snapshot()
await client.complete_instance(42, 3) await client.complete_instance(42, 3)
for call in transport.calls: for call in transport.calls:
assert call["headers"] == { assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"}
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
} def test_client_takes_no_household_timezone() -> None:
with pytest.raises(TypeError):
NestQuestApiClient(
BASE_URL, TOKEN, StubTransport(), household_timezone="UTC"
)
async def test_base_url_trailing_slash_is_normalized() -> None: async def test_base_url_trailing_slash_is_normalized() -> None:
@@ -413,20 +415,13 @@ def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None:
} }
) )
hass = types.SimpleNamespace( # A bare object: building the client must not read hass.config.
config=types.SimpleNamespace(time_zone="America/New_York") client = api_client.client_from_entry(object(), entry)
)
client = api_client.client_from_entry(hass, entry)
assert client._session is shared_session assert client._session is shared_session
assert client._base_url == "http://panel.lan:8443" assert client._base_url == "http://panel.lan:8443"
assert client._panel_token == "entry-token" assert client._panel_token == "entry-token"
assert client._household_timezone == "America/New_York" assert client._headers() == {"Authorization": "Bearer entry-token"}
#: A minimal hass stand-in carrying the configured time zone.
_HASS_UTC = types.SimpleNamespace(config=types.SimpleNamespace(time_zone="UTC"))
def test_client_from_entry_unconfigured_token_raises_at_construction( def test_client_from_entry_unconfigured_token_raises_at_construction(
@@ -449,12 +444,12 @@ def test_client_from_entry_unconfigured_token_raises_at_construction(
assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == "" assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == ""
with pytest.raises(ValueError, match="panel_token must be a non-empty"): with pytest.raises(ValueError, match="panel_token must be a non-empty"):
api_client.client_from_entry(_HASS_UTC, entry) api_client.client_from_entry(object(), entry)
# Same for a whitespace-only stored token: whitespace is not a token. # Same for a whitespace-only stored token: whitespace is not a token.
entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "}) entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "})
with pytest.raises(ValueError, match="panel_token must be a non-empty"): with pytest.raises(ValueError, match="panel_token must be a non-empty"):
api_client.client_from_entry(_HASS_UTC, entry_ws) api_client.client_from_entry(object(), entry_ws)
async def test_client_logs_nothing() -> None: async def test_client_logs_nothing() -> None:
+133 -208
View File
@@ -19,6 +19,8 @@ container's zone) and sets the household zone through the real admin
An unset zone keeps the historical host-local behaviour, and the An unset zone keeps the historical host-local behaviour, and the
missed-sweep scheduler waits for the rollover in household time. missed-sweep scheduler waits for the rollover in household time.
Nothing sets the zone automatically: a timezone header on a panel
request never changes the stored settings (task 7f685179).
""" """
from __future__ import annotations from __future__ import annotations
@@ -47,6 +49,7 @@ _settings = importlib.import_module("nestquest_core.settings")
_settings_store = importlib.import_module("nestquest_core.settings_store") _settings_store = importlib.import_module("nestquest_core.settings_store")
_core_db = importlib.import_module("nestquest_core.db") _core_db = importlib.import_module("nestquest_core.db")
_core_migrations = importlib.import_module("nestquest_core.migrations") _core_migrations = importlib.import_module("nestquest_core.migrations")
_dao_meta = importlib.import_module("nestquest_core.dao_meta")
#: The panel service token the admin harness app is configured with. #: The panel service token the admin harness app is configured with.
HARNESS_PANEL_TOKEN = "admin-test-panel-token" HARNESS_PANEL_TOKEN = "admin-test-panel-token"
@@ -84,7 +87,12 @@ async def household(temp_db_path: str, monkeypatch) -> SimpleNamespace:
monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"]) monkeypatch.setattr(routes_panel, "_local_now", lambda: clock["now"])
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner as client:
yield SimpleNamespace(client=client, seed=seed, clock=clock) yield SimpleNamespace(
client=client,
seed=seed,
clock=clock,
database=runner.app.state.db.database,
)
async def _set_zone(household: SimpleNamespace, zone: str) -> None: async def _set_zone(household: SimpleNamespace, zone: str) -> None:
@@ -237,23 +245,14 @@ def test_from_options_reads_and_resiliently_defaults_timezone() -> None:
) )
def test_settings_timezone_configured_defaults_false_and_is_strict() -> None: def test_settings_have_no_timezone_configured_marker() -> None:
assert _settings.NestQuestSettings().timezone_configured is False assert not hasattr(_settings.NestQuestSettings(), "timezone_configured")
assert _settings.NestQuestSettings.from_options({}).timezone_configured is False with pytest.raises(TypeError):
assert ( _settings.NestQuestSettings(timezone_configured=True)
_settings.NestQuestSettings.from_options( # A stored document still carrying the removed key loads harmlessly.
{"timezone_configured": True} assert _settings.NestQuestSettings.from_options_resilient(
).timezone_configured {"timezone": HOUSEHOLD_ZONE, "timezone_configured": True}
is True ) == _settings.NestQuestSettings(timezone=HOUSEHOLD_ZONE)
)
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 ----------------------------------------------
@@ -329,37 +328,29 @@ def test_delay_seconds_is_true_elapsed_time_across_dst(
assert _delay_seconds(target, now) == expected assert _delay_seconds(target, now) == expected
# --- the Home Assistant-reported zone (task 6351b36d) ------------------------ # --- nothing sets the zone automatically (task 7f685179) --------------------
# #
# The deployed household never set ``timezone``, so the UTC API host marked # The zone is only ever set by an admin's explicit settings update. A
# morning quests overdue. The integration reports Home Assistant's # timezone header on a panel request — the one Home Assistant used to
# ``time_zone`` on every panel request; while the admin has not chosen a # report, or any look-alike — is ignored, and no read writes settings.
# zone the API adopts it (persisted, so the scheduler and the admin plane
# follow too), and an explicit admin choice still wins. #: Timezone-looking request headers a panel request might carry.
_TIMEZONE_HEADERS = [
{"X-NestQuest-Timezone": HOUSEHOLD_ZONE},
{"X-NestQuest-Time-Zone": HOUSEHOLD_ZONE},
{"X-Timezone": HOUSEHOLD_ZONE},
{"Time-Zone": HOUSEHOLD_ZONE},
]
def _reporting_panel_headers(zone: str) -> dict[str, str]: async def _stored_document(household: SimpleNamespace) -> str | None:
return {**_panel_headers(), "X-NestQuest-Timezone": zone} """The raw stored settings document (``None`` when never written)."""
return await _dao_meta.MetaStateDao(household.database).get(
_settings_store.SETTINGS_META_KEY
async def _panel_pack_bag(
household: SimpleNamespace, zone: str
) -> tuple[str, dict]:
response = await household.client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
) )
assert response.status_code == 200
body = response.json()
ada = next(
child
for child in body["children"]
if child["child_id"] == household.seed.ada.id
)
(instance,) = ada["instances"]
return body["today_iso"], instance
async def _stored_settings(household: SimpleNamespace) -> dict: async def _admin_settings(household: SimpleNamespace) -> dict:
response = await household.client.get( response = await household.client.get(
"/api/v1/admin/settings", headers=_admin_headers() "/api/v1/admin/settings", headers=_admin_headers()
) )
@@ -367,137 +358,73 @@ async def _stored_settings(household: SimpleNamespace) -> dict:
return response.json() return response.json()
async def test_unset_zone_adopts_reported_zone_for_overdue( @pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
household: SimpleNamespace, async def test_panel_timezone_header_never_changes_settings(
household: SimpleNamespace, extra: dict[str, str]
) -> None: ) -> None:
"""09:59:59 EDT on a UTC host, zone never set: NOT overdue.""" """09:59:59 EDT on a UTC host, zone unset: the header is ignored."""
household.clock["now"] = _utc(13, 59, 59) household.clock["now"] = _utc(13, 59, 59)
today_iso, instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE) headers = {**_panel_headers(), **extra}
assert today_iso == TODAY.isoformat() snapshot = await household.client.get(
assert instance["overdue"] is False "/api/v1/panel/snapshot", headers=headers
# Persisted as the automatic (not admin-chosen) household zone, so )
# the admin plane and the missed-sweep scheduler read it too. assert snapshot.status_code == 200
stored = await _stored_settings(household)
assert stored["timezone"] == HOUSEHOLD_ZONE
assert stored["timezone_configured"] is False
_today, admin_instance = await _pack_bag(household, "admin")
assert admin_instance["overdue"] is False
async def test_unset_zone_adopts_reported_zone_for_today(
household: SimpleNamespace,
) -> None:
"""23:30 EDT is tomorrow in UTC; the household day is still today."""
household.clock["now"] = _utc(3, 30, day=27)
today_iso, _instance = await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert today_iso == TODAY.isoformat()
async def test_reported_zone_completion_is_on_time(
household: SimpleNamespace,
) -> None:
"""A 09:30 EDT tap with the zone never set is on time."""
household.clock["now"] = _utc(13, 30)
seed = household.seed seed = household.seed
response = await household.client.post( completed = await household.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete", f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers=_reporting_panel_headers(HOUSEHOLD_ZONE), headers=headers,
json={"actor_child_id": seed.ada.id}, json={"actor_child_id": seed.ada.id},
) )
assert response.status_code == 200 assert completed.status_code == 200
# Nothing was written, and the clock stayed host local (UTC): the
# 10:00 quest was overdue and the 13:59 UTC completion was late.
assert await _stored_document(household) is None
assert (await _admin_settings(household))["timezone"] == ""
_today_iso, instance = await _pack_bag(household, "admin") _today_iso, instance = await _pack_bag(household, "admin")
assert instance["on_time"] is True assert instance["on_time"] is False
async def test_reported_zone_follows_home_assistant_changes( @pytest.mark.parametrize("extra", _TIMEZONE_HEADERS)
household: SimpleNamespace, async def test_panel_timezone_header_never_replaces_the_stored_zone(
household: SimpleNamespace, extra: dict[str, str]
) -> None: ) -> None:
"""An automatically adopted zone tracks a later Home Assistant change.""" """A stored zone stays exactly as the admin saved it."""
await _panel_pack_bag(household, "America/Chicago") await _set_zone(household, "America/Los_Angeles")
await _panel_pack_bag(household, HOUSEHOLD_ZONE) before = await _stored_document(household)
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE household.clock["now"] = _utc(16, 59, 59) # 09:59:59 PDT, 12:59:59 EDT
response = await household.client.get(
"/api/v1/panel/snapshot",
async def _adopt_new_york(household: SimpleNamespace) -> None: headers={**_panel_headers(), **extra},
"""Prove automatic adoption first, so later assertions can't pass
on code that ignores the reported zone altogether."""
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
stored = await _stored_settings(household)
assert stored["timezone"] == HOUSEHOLD_ZONE
assert stored["timezone_configured"] is False
@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""])
async def test_admin_chosen_zone_wins_over_reported_zone(
household: SimpleNamespace, chosen: str
) -> None:
await _adopt_new_york(household)
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": chosen, "timezone_configured": True},
headers=_admin_headers(),
) )
assert response.status_code == 200 assert response.status_code == 200
# A conflicting report is no longer adopted. assert await _stored_document(household) == before
await _panel_pack_bag(household, "America/Chicago") _today_iso, instance = await _pack_bag(household, "panel")
stored = await _stored_settings(household)
assert stored["timezone"] == chosen
assert stored["timezone_configured"] is True
# --- a timezone-only admin update is an explicit choice (task 45243478) ------
@pytest.mark.parametrize("chosen", ["America/Los_Angeles", ""])
async def test_timezone_only_admin_update_is_an_explicit_choice(
household: SimpleNamespace, chosen: str
) -> None:
"""``PATCH {"timezone": ...}`` alone marks the zone admin-chosen."""
await _adopt_new_york(household)
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": chosen},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["timezone_configured"] is True
# A conflicting report is no longer adopted.
await _panel_pack_bag(household, "America/Chicago")
stored = await _stored_settings(household)
assert stored["timezone"] == chosen
assert stored["timezone_configured"] is True
async def test_explicit_timezone_configured_false_is_respected(
household: SimpleNamespace,
) -> None:
"""A supplied ``timezone_configured`` is persisted exactly as given."""
response = await household.client.patch(
"/api/v1/admin/settings",
json={"timezone": "America/Chicago", "timezone_configured": False},
headers=_admin_headers(),
)
assert response.status_code == 200
assert response.json()["timezone_configured"] is False
await _panel_pack_bag(household, HOUSEHOLD_ZONE)
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
async def test_invalid_reported_zone_is_ignored(
household: SimpleNamespace,
) -> None:
await _adopt_new_york(household)
household.clock["now"] = _utc(13, 59, 59)
_today_iso, instance = await _panel_pack_bag(household, "Mars/Olympus_Mons")
# Still read in the adopted zone: 09:59:59 EDT is not overdue.
assert instance["overdue"] is False assert instance["overdue"] is False
assert (await _stored_settings(household))["timezone"] == HOUSEHOLD_ZONE
# --- the scheduler follows an adopted zone (task 6351b36d) ------------------- async def test_fresh_install_zone_stays_empty_across_reads(
household: SimpleNamespace,
) -> None:
"""Repeated panel and admin reads never write the settings document."""
for hour in (3, 13, 23):
household.clock["now"] = _utc(hour, 0)
for extra in _TIMEZONE_HEADERS:
response = await household.client.get(
"/api/v1/panel/snapshot",
headers={**_panel_headers(), **extra},
)
assert response.status_code == 200
await _pack_bag(household, "admin")
settings = await _admin_settings(household)
assert settings["timezone"] == ""
assert "timezone_configured" not in settings
assert await _stored_document(household) is None
# --- the scheduler follows a changed zone (task 6351b36d) --------------------
# #
# On a UTC host with no stored zone the scheduler first plans for UTC # On a UTC host with no stored zone the scheduler first plans for UTC
# midnight; a zone adopted while it sleeps must move the sweep to the # midnight; a zone stored while it sleeps must move the sweep to the
# household's midnight rather than sweep at 20:00 EDT. # household's midnight rather than sweep at 20:00 EDT.
#: 13:00 UTC (09:00 EDT) to UTC midnight. #: 13:00 UTC (09:00 EDT) to UTC midnight.
@@ -506,11 +433,20 @@ _UTC_MIDNIGHT_DELAY = 11 * 3600
_NEW_YORK_MIDNIGHT_DELAY = 15 * 3600 _NEW_YORK_MIDNIGHT_DELAY = 15 * 3600
async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping( async def _store_zone(scheduler, database, zone: str) -> None:
"""Store ``zone`` the way a settings writer must: under the lock."""
async with scheduler.settings_lock:
settings = await _settings_store.update_settings(
database, {"timezone": zone}
)
scheduler.settings_changed(settings)
async def test_scheduler_replans_when_zone_changes_while_sleeping(
temp_db_path: str, temp_db_path: str,
) -> None: ) -> None:
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner:
app_state = runner.app.state app_state = runner.app.state
await app_state.sweep_scheduler.stop() await app_state.sweep_scheduler.stop()
delays: list[float] = [] delays: list[float] = []
@@ -532,11 +468,9 @@ async def test_scheduler_replans_when_panel_adopts_zone_while_sleeping(
await asyncio.wait_for( await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5 slept.wait_for(lambda: len(delays) == 1), timeout=5
) )
response = await client.get( await _store_zone(
"/api/v1/panel/snapshot", scheduler, app_state.db.database, HOUSEHOLD_ZONE
headers=_reporting_panel_headers(HOUSEHOLD_ZONE),
) )
assert response.status_code == 200
async with slept: async with slept:
await asyncio.wait_for( await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 2), timeout=5 slept.wait_for(lambda: len(delays) == 2), timeout=5
@@ -574,8 +508,8 @@ async def test_scheduler_rechecks_settings_before_sweeping(
if len(delays) == 1: if len(delays) == 1:
# The UTC rollover "arrives" after the zone was stored # The UTC rollover "arrives" after the zone was stored
# behind the scheduler's back. # behind the scheduler's back.
await _settings_store.adopt_reported_timezone( await _settings_store.update_settings(
database, HOUSEHOLD_ZONE database, {"timezone": HOUSEHOLD_ZONE}
) )
return return
replanned.set() replanned.set()
@@ -598,12 +532,12 @@ async def test_scheduler_rechecks_settings_before_sweeping(
await database.close() await database.close()
# --- adoption is serialised with planning and sweeping (TZ-003) -------------- # --- settings writes are serialised with planning and sweeping (TZ-003) ------
# #
# An adoption racing the loop's own settings reads must never leave a # A zone write racing the loop's own settings reads must never leave a
# stale plan behind. Each test holds the loop at one of its reads and # stale plan behind. Each test holds the loop at one of its reads and
# issues a real panel adoption there; when the scheduler's settings lock # issues a locked zone write there; when the scheduler's settings lock
# is held at that point the adoption provably cannot commit until the # is held at that point the write provably cannot commit until the
# loop moves on, otherwise it is awaited to commit inside the window. # loop moves on, otherwise it is awaited to commit inside the window.
@@ -626,24 +560,12 @@ def _hold_scheduler_read(monkeypatch, scheduler, nth: int, during):
return real_load return real_load
async def _adopt_via(client, zone: str) -> None: async def _store_inside_read(scheduler, database, zone: str) -> asyncio.Task:
response = await client.get( """Start a locked write of ``zone``; let it commit unless serialised."""
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone) write = asyncio.create_task(_store_zone(scheduler, database, zone))
) if not scheduler.settings_lock.locked():
assert response.status_code == 200 await write
return write
async def _adopt_inside_read(client, scheduler, zone: str) -> asyncio.Task:
"""Start a panel adoption of ``zone``; let it commit unless serialised."""
adoption = asyncio.create_task(
client.get(
"/api/v1/panel/snapshot", headers=_reporting_panel_headers(zone)
)
)
lock = getattr(scheduler, "settings_lock", None)
if lock is None or not lock.locked():
await adoption
return adoption
async def test_scheduler_follows_successive_zone_changes( async def test_scheduler_follows_successive_zone_changes(
@@ -651,8 +573,9 @@ async def test_scheduler_follows_successive_zone_changes(
) -> None: ) -> None:
"""New York -> UTC -> New York while the re-plan read is in flight.""" """New York -> UTC -> New York while the re-plan read is in flight."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner:
app_state = runner.app.state app_state = runner.app.state
database = app_state.db.database
await app_state.sweep_scheduler.stop() await app_state.sweep_scheduler.stop()
delays: list[float] = [] delays: list[float] = []
slept = asyncio.Condition() slept = asyncio.Condition()
@@ -664,15 +587,15 @@ async def test_scheduler_follows_successive_zone_changes(
await asyncio.Event().wait() await asyncio.Event().wait()
scheduler = MissedSweepScheduler( scheduler = MissedSweepScheduler(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub database, clock=lambda: _utc(13, 0), sleep=sleep_stub
) )
app_state.sweep_scheduler = scheduler app_state.sweep_scheduler = scheduler
await _adopt_via(client, HOUSEHOLD_ZONE) await _store_zone(scheduler, database, HOUSEHOLD_ZONE)
adoptions: list[asyncio.Task] = [] writes: list[asyncio.Task] = []
async def back_to_new_york(_settings) -> None: async def back_to_new_york(_settings) -> None:
adoptions.append( writes.append(
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE) await _store_inside_read(scheduler, database, HOUSEHOLD_ZONE)
) )
# Read 1 plans New York; read 2 is the re-plan after UTC. # Read 1 plans New York; read 2 is the re-plan after UTC.
@@ -683,16 +606,16 @@ async def test_scheduler_follows_successive_zone_changes(
await asyncio.wait_for( await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 1), timeout=5 slept.wait_for(lambda: len(delays) == 1), timeout=5
) )
await _adopt_via(client, "UTC") await _store_zone(scheduler, database, "UTC")
async with slept: async with slept:
await asyncio.wait_for( await asyncio.wait_for(
slept.wait_for(lambda: len(delays) == 3), timeout=5 slept.wait_for(lambda: len(delays) == 3), timeout=5
) )
(adoption,) = adoptions (write,) = writes
assert (await adoption).status_code == 200 await write
finally: finally:
await scheduler.stop() await scheduler.stop()
# The in-flight read planned UTC; the New York adoption that followed # The in-flight read planned UTC; the New York write that followed
# it woke the wait, so the scheduler sleeps on New York midnight. # it woke the wait, so the scheduler sleeps on New York midnight.
assert delays == [ assert delays == [
pytest.approx(_NEW_YORK_MIDNIGHT_DELAY), pytest.approx(_NEW_YORK_MIDNIGHT_DELAY),
@@ -701,12 +624,12 @@ async def test_scheduler_follows_successive_zone_changes(
] ]
async def test_adoption_after_presweep_check_cannot_precede_the_sweep( async def test_zone_write_after_presweep_check_cannot_precede_the_sweep(
temp_db_path: str, monkeypatch temp_db_path: str, monkeypatch
) -> None: ) -> None:
"""No sweep runs once an adoption has replaced its plan.""" """No sweep runs once a zone write has replaced its plan."""
runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID))
async with runner as client: async with runner:
app_state = runner.app.state app_state = runner.app.state
await app_state.sweep_scheduler.stop() await app_state.sweep_scheduler.stop()
delays: list[float] = [] delays: list[float] = []
@@ -723,16 +646,18 @@ async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub app_state.db.database, clock=lambda: _utc(13, 0), sleep=sleep_stub
) )
app_state.sweep_scheduler = scheduler app_state.sweep_scheduler = scheduler
adoptions: list[asyncio.Task] = [] writes: list[asyncio.Task] = []
async def adopt_new_york(_settings) -> None: async def store_new_york(_settings) -> None:
adoptions.append( writes.append(
await _adopt_inside_read(client, scheduler, HOUSEHOLD_ZONE) await _store_inside_read(
scheduler, app_state.db.database, HOUSEHOLD_ZONE
)
) )
# Read 1 plans UTC; read 2 is the pre-sweep re-check. # Read 1 plans UTC; read 2 is the pre-sweep re-check.
real_load = _hold_scheduler_read( real_load = _hold_scheduler_read(
monkeypatch, scheduler, 2, adopt_new_york monkeypatch, scheduler, 2, store_new_york
) )
swept_under: list[str] = [] swept_under: list[str] = []
@@ -746,12 +671,12 @@ async def test_adoption_after_presweep_check_cannot_precede_the_sweep(
scheduler.start() scheduler.start()
try: try:
await asyncio.wait_for(replanned.wait(), timeout=5) await asyncio.wait_for(replanned.wait(), timeout=5)
(adoption,) = adoptions (write,) = writes
assert (await adoption).status_code == 200 await write
finally: finally:
await scheduler.stop() await scheduler.stop()
# The sweep ran for the UTC plan with UTC still stored, never after # The sweep ran for the UTC plan with UTC still stored, never after
# New York had been adopted; the next plan is New York midnight. # New York had been stored; the next plan is New York midnight.
assert swept_under == [""] assert swept_under == [""]
assert delays == [ assert delays == [
pytest.approx(_UTC_MIDNIGHT_DELAY), pytest.approx(_UTC_MIDNIGHT_DELAY),
+2 -2
View File
@@ -686,8 +686,8 @@ async def test_coordinator_configured_token_builds_real_client(
hass, entry, session_factory=_session_factory hass, entry, session_factory=_session_factory
) )
assert isinstance(real_client, NestQuestApiClient) assert isinstance(real_client, NestQuestApiClient)
# Home Assistant's zone is reported to the API (task 6351b36d). # Home Assistant's zone is not reported to the API (task 7f685179).
assert real_client._household_timezone == hass.config.time_zone assert real_client._headers() == {"Authorization": "Bearer tok"}
set_coordinator_client(entry, real_client) set_coordinator_client(entry, real_client)
assert await async_setup_entry(hass, entry) is True assert await async_setup_entry(hass, entry) is True
-18
View File
@@ -351,24 +351,6 @@ async def test_stream_events_yields_documented_frames_and_sends_bearer() -> None
assert call["json"] is None assert call["json"] is None
async def test_stream_events_reports_household_timezone() -> None:
"""The SSE GET carries Home Assistant's zone beside the Bearer token
(task 6351b36d), like the snapshot and completion requests."""
frame = _frame_text(*DOCUMENTED_FRAMES[0])
transport = _StreamTransport(_StreamingResponse(200, [frame]))
client = NestQuestApiClient(
BASE_URL, TOKEN, transport, household_timezone="America/New_York"
)
frames = [frame async for frame in client.stream_events()]
assert frames == [DOCUMENTED_FRAMES[0]]
assert transport.calls[0]["headers"] == {
"Authorization": f"Bearer {TOKEN}",
"X-NestQuest-Timezone": "America/New_York",
}
async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None: async def test_stream_events_buffers_lines_across_chunks_and_joins_data() -> None:
"""Chunk-split lines parse, and multi-line data joins per SSE rules.""" """Chunk-split lines parse, and multi-line data joins per SSE rules."""
first_type, first_payload = DOCUMENTED_FRAMES[0] first_type, first_payload = DOCUMENTED_FRAMES[0]