From ec7c8d4bc2a1484effdb8de6d18d22952c433ee9 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 06:02:26 -0400 Subject: [PATCH 1/2] Coordinator polls the API snapshot and rebuilds the sensors' data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature 18: the shared coordinator's refresh pass now fetches GET /api/v1/panel/snapshot through the NestQuestApiClient and reconstructs the exact core dataclasses (NestQuestSnapshot, ChildDaySnapshot, QuestInstanceView) the Feature 10 entities read, so sensor.py and binary_sensor.py are untouched and every entity_id and attribute payload keeps its shape. Why: the integration becomes an API client of the NestQuest service instead of a second reader of the SQLite file, and the panel cards and the four blueprints keep working without changes. - snapshot_from_api_payload maps the route's JSON back onto the dataclasses field by field (state 'completed' -> 'done' so instance_payload renders 'completed' again; counts, presence, next_present, overdue, on_time, completed_at pass through verbatim; due_date is stamped from today_iso, the only date the route serves). - The panel payload omits missed instances (D-009), so admin_instances can no longer carry missed rows — the admin surface for missed quests is the PWA reading the API directly. Documented in the module docstring and pinned by tests. - An unconfigured panel token (the shipped default) gets a typed-error stub instead of a construction crash: setup succeeds, the first refresh fails cleanly, entities come up unavailable and HA's retry cycle applies. Fetch failures log the typed error and mark the entities unavailable while data keeps its last good snapshot. - The runtime record registers before the first refresh so the refresh path always finds the live entry state; the update interval and the serialized forced-refresh behaviour are unchanged. - Tests: a fixture snapshot drives the coordinator (stub client, no network) asserting per-child values, household rollups, cycle day, and the exact instance payload shape, plus unconfigured and failed-fetch cases; DB-seeded tests ride a lazy local-snapshot client through the same JSON path. --- custom_components/nestquest/__init__.py | 37 +- custom_components/nestquest/coordinator.py | 228 +++++-- tests/conftest.py | 121 ++++ tests/test_coordinator.py | 660 +++++++++++++-------- tests/test_core_snapshot.py | 47 +- tests/test_entity_matrix.py | 15 +- tests/test_sensors.py | 34 +- 7 files changed, 825 insertions(+), 317 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 6b0eac4..c05e392 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_change +from .api_client import NestQuestApiError from .const import ( CONF_ADMIN_USER_IDS, CONF_UPDATE_INTERVAL, @@ -26,7 +27,7 @@ from .const import ( ) from .core.settings import NestQuestSettings from .frontend import async_register_frontend -from .coordinator import NestQuestCoordinator +from .coordinator import NestQuestCoordinator, coordinator_client_from_entry from .db import NestQuestDatabase, make_database from .materialize import materialize as _materialize_run from .migrations import apply_migrations @@ -490,18 +491,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _register_services(hass) # The shared Feature 10 coordinator: one refresh cycle every # entity reads from (CONF_UPDATE_INTERVAL seconds, default - # five minutes). Created AFTER services so a failure below - # unwinds them through the except path's listener handling. + # five minutes). Since Feature 18 it polls the API service's + # panel snapshot instead of reading the local database; an + # entry with no panel token yet gets the typed-error stub + # (unavailable entities, never a setup crash). Created AFTER + # services so a failure below unwinds them through the except + # path's listener handling. coordinator = NestQuestCoordinator( hass, entry_id=entry.entry_id, - database=database, - settings=settings, + api_client=coordinator_client_from_entry(hass, entry), update_interval_seconds=entry.options.get( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL ), ) - await coordinator.async_config_entry_first_refresh() runtime_data = NestQuestRuntimeData( entry_id=entry.entry_id, settings=settings, @@ -510,11 +513,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_time_change_listener=remove_time_change_listener, coordinator=coordinator, ) - # runtime_data is assigned BEFORE the platforms forward so each - # platform's async_setup_entry reads its coordinator off the - # entry (the HA runtime-data contract); a forward failure below + # The runtime record is registered BEFORE the first refresh: + # the refresh path (and anything it calls) resolves the entry's + # runtime state through hass.data, and it must find the live + # database there — never a half-set-up entry. The platforms + # forward below reads the coordinator off the entry + # (the HA runtime-data contract); a forward failure below # unwinds it through the except path. + hass.data[DOMAIN][entry.entry_id] = runtime_data entry.runtime_data = runtime_data + # The first refresh polls the API snapshot; an API failure + # (unconfigured token, API service down) does NOT crash setup: + # the coordinator reports last_update_success=False, entities + # come up unavailable, and HA's standard retry cycle applies + # once the API is reachable. Any non-API error still raises. + try: + await coordinator.async_config_entry_first_refresh() + except NestQuestApiError: + pass await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except BaseException: # A failure anywhere after the database is open must not leak the @@ -552,6 +568,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise assert remove_update_listener is not None assert remove_time_change_listener is not None + # The runtime record was already registered before the first + # refresh; re-assert the (unchanged) record so the return path + # stays readable as one registration site. hass.data[DOMAIN][entry.entry_id] = runtime_data return True diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 5a26876..bc7596a 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -1,4 +1,4 @@ -"""The shared NestQuest DataUpdateCoordinator (Feature 10). +"""The shared NestQuest DataUpdateCoordinator (Feature 10, Feature 18). ONE refresh pass builds the whole entity snapshot: active children, today's instances with their derived states (open / done / missed via @@ -7,40 +7,47 @@ per-child rollup counts. Every Feature 10 entity reads ``coordinator.data`` — no entity queries the database itself, so one refresh updates the whole board coherently. -"Today" and "now" are HA-local (``hass.config.time_zone``), never the -host clock and never UTC, matching the materialization and completion -layers. +Since Feature 18 the snapshot no longer comes from the local database: +the coordinator polls the NestQuest API service's panel plane — +``GET /api/v1/panel/snapshot`` through +:class:`~.api_client.NestQuestApiClient` on its update interval — and +RECONSTRUCTS the same core dataclasses the local builder produced +(:class:`~.core.snapshot.NestQuestSnapshot`, +:class:`~.core.snapshot.ChildDaySnapshot`, +:class:`~.core.snapshot.QuestInstanceView`). ``sensor.py`` and +``binary_sensor.py`` read ``coordinator.data`` and +:func:`~.core.snapshot.instance_payload` exactly as before, so every +entity_id and attribute payload shape is unchanged: the panel cards and +the four blueprints keep working untouched. -The snapshot assembly itself lives in the Home-Assistant-free -:mod:`custom_components.nestquest.core.snapshot` module -(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin -HA wrapper that resolves the local timezone and reads the settings handed -into its constructor, then delegates to that builder, passing the SAME -HA-local ``now`` it resolved in one clock read. The API service's panel -snapshot route shares the same builder, so both consumers render ONE -snapshot shape. +One loss is deliberate and documented (D-009 revisited): the panel +payload OMITS missed instances, so a snapshot rebuilt from the API can +no longer carry them — ``admin_instances`` (``include_missed=True``) +now equals ``instances`` (they can hold only open and completed rows). +The admin surface for missed quests is the PWA, which reads the API +service directly; the integration's entities never needed missed rows +for their states or counts (the counts arrive precomputed). -Presence is resolved through the SAME engine the materializer uses -(:class:`~.presence.PresenceEngine`): schedules and overrides are read -with the public DAO methods and the engine's anchor-date arithmetic -answers ``is this child at this house today`` (D-004 — never ISO week -parity). - -``cycle_day`` reports where today falls in the custody cycle of the -first active child (display order) that HAS a schedule — 1-based day -``N`` of ``cycle_length_weeks * 7``, computed from that schedule's -anchor date. ``0`` means no active child has a schedule at all, so -the admin header degrades to a harmless zero instead of guessing. +Failure handling: constructing the client fails fast (ValueError) when +the entry has no panel token configured, and a fetch failure raises the +typed :class:`~.api_client.NestQuestApiError`. Neither crashes +integration setup: the constructor logs a clear message and hands back +a client-shaped stub whose refresh raises the same typed error, so the +coordinator's refresh pass marks the entities unavailable +(``last_update_success = False``, ``data = None``) — exactly how a HA +``DataUpdateCoordinator`` surfaces a failed update. The typed error is +never swallowed silently: it is logged with its message and chained. """ from __future__ import annotations import asyncio import datetime -from zoneinfo import ZoneInfo +from typing import Any from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config from .const import ( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, @@ -48,13 +55,10 @@ from .const import ( LOGGER, MIN_UPDATE_INTERVAL, ) -from .core.db import NestQuestDatabase -from .core.settings import NestQuestSettings from .core.snapshot import ( ChildDaySnapshot, NestQuestSnapshot, QuestInstanceView, - build_snapshot, ) __all__ = [ @@ -65,9 +69,113 @@ __all__ = [ ] +def snapshot_from_api_payload(payload: dict[str, Any]) -> NestQuestSnapshot: + """Reconstruct the core snapshot dataclasses from the API's JSON. + + The panel snapshot route (``api/routes_panel.py``) returns today's + household as plain JSON — ``today_iso``, ``cycle_day``, and the + per-child list with precomputed counts and the instances in the + documented payload shape. This function maps that shape BACK onto + the dataclasses ``sensor.py`` / ``binary_sensor.py`` read, field by + field: + + - the API's ``state`` ``"completed"`` maps back to the core view's + ``"done"`` (so :func:`~.core.snapshot.instance_payload` renders + ``"completed"`` again — the round-trip is identity for open and + completed rows); + - ``overdue``, ``on_time`` (→ ``was_on_time``), ``completed_at``, + ``window``, ``due_time``, ``icon`` and the per-child counts pass + through verbatim; + - ``present`` and ``next_present`` pass through verbatim (the API + resolved them through the same presence engine server-side); + - the instance list is rebuilt IN ORDER, so the panel payload the + sensors emit matches the route's order byte for byte. + + Because the route omits missed instances (D-009), a snapshot built + here cannot contain any ``missed`` view — see the module docstring. + """ + children: list[ChildDaySnapshot] = [] + for child in payload.get("children", []): + views = tuple( + QuestInstanceView( + instance_id=instance["id"], + definition_id=instance["definition_id"], + child_id=instance["child_id"], + title=instance["title"], + icon=instance.get("icon"), + window=instance["window"], + # The API payload does not carry due_date: every row + # is TODAY's instance (the route queries + # [today, today]), so the view's due_date is the + # snapshot's today_iso — the same value the local + # builder stamped on every today row. + due_date=payload["today_iso"], + due_time=instance.get("due_time"), + state=( + "done" if instance["state"] == "completed" else + instance["state"] + ), + overdue=instance["overdue"], + completed_at=instance.get("completed_at"), + was_on_time=instance.get("on_time"), + ) + for instance in child.get("instances", []) + ) + children.append( + ChildDaySnapshot( + child_id=child["child_id"], + child_name=child["child_name"], + instances=views, + present=child["present"], + due_today=child["due_today"], + completed_today=child["completed_today"], + remaining_today=child["remaining_today"], + completion_pct=child["completion_pct"], + next_present=child.get("next_present"), + ) + ) + return NestQuestSnapshot( + today_iso=payload["today_iso"], + children=tuple(children), + cycle_day=payload["cycle_day"], + ) + + +class _UnconfiguredApiClient: + """Client-shaped stand-in used when the entry has no panel token. + + Constructing a real :class:`~.api_client.NestQuestApiClient` with an + empty token raises ValueError by design (fail fast on a client that + could only ever 401). An entry that simply has not been configured + yet must not crash integration setup, so the coordinator swaps in + this stub: every refresh raises the SAME typed + :class:`~.api_client.NestQuestApiError` a transport failure would, + carrying a clear "API not configured" message, so the coordinator + marks the entities unavailable and the HA standard retry cycle + applies. The error is logged, never silent. + """ + + __slots__ = () + + async def get_snapshot(self) -> dict[str, Any]: + """Always raise: there is no configured API to fetch from.""" + raise NestQuestApiError( + "NestQuest API not configured: set the panel token in the " + "integration options" + ) + + class NestQuestCoordinator(DataUpdateCoordinator): """Shared refresh cycle for every NestQuest entity. + One refresh pass = one ``GET /api/v1/panel/snapshot`` through the + API client, reconstructed into the core snapshot dataclasses every + entity reads. A failed pass (unconfigured API, transport failure, + non-2xx response) raises the typed error out of + ``_async_update_data``, so the coordinator's ``data`` keeps its last + good value while ``last_update_success`` goes False — HA marks the + entities unavailable until a later pass succeeds. + Forced refreshes (the service paths pushing an immediate update after a completion) are SERIALIZED per entry: overlapping complete/uncomplete calls must not interleave their snapshot @@ -82,8 +190,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): hass: HomeAssistant, *, entry_id: str, - database: NestQuestDatabase, - settings: NestQuestSettings, + api_client: NestQuestApiClient, update_interval_seconds: int | None = None, ) -> None: interval = update_interval_seconds @@ -100,8 +207,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): update_interval=datetime.timedelta(seconds=interval), ) self.entry_id = entry_id - self.database = database - self.settings = settings + self.api_client = api_client self._refresh_lock = asyncio.Lock() async def async_refresh(self) -> None: @@ -111,12 +217,58 @@ class NestQuestCoordinator(DataUpdateCoordinator): async with self._refresh_lock: await super().async_refresh() - def _local_now(self) -> datetime.datetime: - time_zone = ZoneInfo(self.hass.config.time_zone) - return datetime.datetime.now(time_zone) - async def _async_update_data(self) -> NestQuestSnapshot: - now = self._local_now() - return await build_snapshot( - self.database, self.settings, now + try: + payload = await self.api_client.get_snapshot() + except NestQuestApiError as err: + LOGGER.error( + "NestQuest coordinator refresh failed: %s", err + ) + raise + return snapshot_from_api_payload(payload) + + +def coordinator_client_from_entry( + hass: HomeAssistant, entry: Any, *, session_factory: Any = None +) -> Any: + """Build the refresh client for a config entry, tolerating no token. + + Reads ``(base_url, panel_token)`` off the entry and constructs the + real client. An entry with no panel token configured (the shipped + default is the empty string) makes the constructor raise ValueError + by design; integration setup must not crash on an unconfigured + entry, so this factory swaps in the typed-error stub instead and + logs ONE clear message. The ValueError itself is never re-raised + past this point — it is converted, with the reason logged. + + ``session_factory`` overrides where the HTTP session comes from + (production: HA's shared aiohttp session via + :func:`~.api_client.client_from_entry`); it exists so tests can + inject a stub transport without importing aiohttp — the mock-only + test harness has no ``homeassistant.helpers.aiohttp_client``. + """ + from custom_components.nestquest.api_client import NestQuestApiClient + + base_url, panel_token = resolve_api_config(entry) + if not isinstance(panel_token, str) or not panel_token.strip(): + LOGGER.warning( + "NestQuest API panel token is not configured for entry %s: " + "entities will stay unavailable until a token is set in the " + "integration options", + entry.entry_id, ) + return _UnconfiguredApiClient() + if session_factory is not None: + return NestQuestApiClient( + base_url, panel_token, session_factory(hass) + ) + from custom_components.nestquest.api_client import client_from_entry + + return client_from_entry(hass, entry) + + +#: Test seam: entry_id → client, consulted by the conftest's client +#: factory BEFORE the production path. Always empty in production +#: (nothing in the integration writes to it); tests register scripted +#: clients through it and clear it between tests. +_COORDINATOR_CLIENT_OVERRIDES: dict[str, Any] = {} diff --git a/tests/conftest.py b/tests/conftest.py index ece6b88..86b9b28 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,6 +52,7 @@ _HA_MODULES = ( "homeassistant.helpers.entity", "homeassistant.helpers.event", "homeassistant.helpers.update_coordinator", + "homeassistant.helpers.aiohttp_client", "homeassistant.components", "homeassistant.components.sensor", "homeassistant.components.binary_sensor", @@ -99,6 +100,7 @@ for _parent, _child in ( ("homeassistant.helpers", "entity"), ("homeassistant.helpers", "event"), ("homeassistant.helpers", "update_coordinator"), + ("homeassistant.helpers", "aiohttp_client"), ("homeassistant", "components"), ("homeassistant.components", "sensor"), ("homeassistant.components", "binary_sensor"), @@ -700,6 +702,125 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry): return entry +class LazyLocalSnapshotClient: + """A test client that serves the snapshot built LOCALLY. + + Feature 18 moved the coordinator's refresh onto the API service's + panel snapshot, but most integration tests still seed the local + database directly (the DB removal is a later task). This stub + bridges the two: ``get_snapshot`` resolves the entry's runtime + record (its database and settings) and runs the SAME pure core + builder the API route runs + (:func:`~custom_components.nestquest.core.snapshot.build_snapshot`), + shaping the payload exactly like ``api/routes_panel.py`` — + instances via ``instance_payload(include_missed=False)`` — so the + coordinator reconstructs from a byte-identical payload without any + network or FastAPI machinery. + + The resolution is LAZY on purpose: the coordinator's factory runs + mid-setup, before the runtime record exists, so the first refresh + (and any refresh while the entry is torn down) serves an EMPTY but + valid snapshot; every later refresh reads the seeded database. + """ + + def __init__(self, hass, entry_id: str) -> None: + self._hass = hass + self._entry_id = entry_id + self.calls = 0 + + async def get_snapshot(self): + import datetime + import zoneinfo + + from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN + from custom_components.nestquest.core import snapshot as core_snapshot + + self.calls += 1 + runtime = self._hass.data.get(NQ_DOMAIN, {}).get(self._entry_id) + database = getattr(runtime, "database", None) + if database is None or not getattr(database, "connected", True): + return { + "today_iso": datetime.date.today().isoformat(), + "cycle_day": 0, + "children": [], + } + settings = getattr(runtime, "settings", None) + # The HA-LOCAL now, the same single clock read the production + # coordinator used to hand the builder: the hass stub's + # configured time zone, never the host clock. + now = datetime.datetime.now( + zoneinfo.ZoneInfo(self._hass.config.time_zone) + ) + snapshot = await core_snapshot.build_snapshot(database, settings, now) + return { + "today_iso": snapshot.today_iso, + "cycle_day": snapshot.cycle_day, + "children": [ + { + "child_id": child.child_id, + "child_name": child.child_name, + "present": child.present, + "next_present": child.next_present, + "due_today": child.due_today, + "completed_today": child.completed_today, + "remaining_today": child.remaining_today, + "completion_pct": child.completion_pct, + "instances": core_snapshot.instance_payload( + child.instances, include_missed=False + ), + } + for child in snapshot.children + ], + } + + +@pytest.fixture(autouse=True) +def coordinator_serves_local_snapshot(): + """Default every test entry's coordinator to the local-snapshot client. + + The integration's setup builds its API client through + ``coordinator_client_from_entry``; the fixture swaps that factory + for a test one handing out :class:`LazyLocalSnapshotClient` (so + DB-seeded tests exercise the coordinator's API path against local + data). Tests that script their own client register it with + :func:`set_coordinator_client`, which the factory honours first. + """ + import custom_components.nestquest as nq_mod + import custom_components.nestquest.coordinator as coordinator_module + from custom_components.nestquest.coordinator import ( + coordinator_client_from_entry as production_factory, + ) + + _COORDINATOR_CLIENT_OVERRIDES = ( + coordinator_module._COORDINATOR_CLIENT_OVERRIDES + ) + + def _factory(hass, entry, **kwargs): + override = _COORDINATOR_CLIENT_OVERRIDES.get(entry.entry_id) + if override is not None: + return override + return LazyLocalSnapshotClient(hass, entry.entry_id) + + import unittest.mock as mock + + _COORDINATOR_CLIENT_OVERRIDES.clear() + patcher = mock.patch.object( + nq_mod, "coordinator_client_from_entry", _factory + ) + patcher.start() + yield + patcher.stop() + _COORDINATOR_CLIENT_OVERRIDES.clear() + + +def set_coordinator_client(entry, client): + """Register a scripted client for ``entry``'s coordinator factory.""" + import custom_components.nestquest.coordinator as coordinator_module + + coordinator_module._COORDINATOR_CLIENT_OVERRIDES[entry.entry_id] = client + return client + + class _HassNamespace(SimpleNamespace): """SimpleNamespace subclass so the hass stub can hold weak references.""" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 415318e..5b9b818 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1,263 +1,452 @@ -"""Tests for the shared NestQuest coordinator (Feature 10, task 1).""" +"""Tests for the shared NestQuest coordinator (Feature 10, Feature 18). + +The coordinator now polls the API service's panel snapshot through the +API client (Feature 18) and reconstructs the core snapshot dataclasses +every entity reads. The tests stub the client — NO network, NO aiohttp +— with a FIXTURE snapshot payload (the documented shape of the Feature +16 snapshot route, ``api/routes_panel.py``) and assert the +reconstruction: per-child counts and presence, the instance views +(``completed`` mapped back to ``done``), the household rollups, the +cycle day, and the unconfigured / failed-fetch behaviour. +""" from __future__ import annotations import datetime import pytest -from conftest import wire_entry_to_registry +from conftest import set_coordinator_client, wire_entry_to_registry from custom_components.nestquest import async_setup_entry, async_unload_entry -from custom_components.nestquest.children import ( - create_child, - list_children, - set_child_active, +from custom_components.nestquest.api_client import ( + NestQuestApiClient, + NestQuestApiError, ) -from custom_components.nestquest.completion import complete_instance from custom_components.nestquest.const import ( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, - DEFAULT_HORIZON_DAYS, DOMAIN, ) -from custom_components.nestquest.coordinator import NestQuestCoordinator -from custom_components.nestquest.dao_presence import PresenceSchedulesDao -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, +from custom_components.nestquest.coordinator import ( + NestQuestCoordinator, + coordinator_client_from_entry, + snapshot_from_api_payload, ) -from custom_components.nestquest.recurrence import ScheduleRule - -ADMIN_CTX = {"user_id": "admin-1"} +from custom_components.nestquest.core.snapshot import instance_payload def _today_iso() -> str: return datetime.date.today().isoformat() -async def _seed_day(database, *, completed: bool = False) -> None: - """Two active children + one inactive, one daily definition each.""" - await create_child(database, "Ada") - await create_child(database, "Bo") - inactive = await create_child(database, "Ghost") - await set_child_active(database, inactive.id, False) - today = _today_iso() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict({"rule_type": "daily", "start_date": today}), - [child.id for child in await list_children(database, active_only=True)], - ["morning"], +class StubSnapshotClient: + """A no-network stand-in for the API client's snapshot surface. + + Answers ``get_snapshot()`` from a scripted queue of outcomes — a + payload dict or an exception per call, consumed in order — and + records every call, so each test drives exactly one refresh. + """ + + def __init__(self, *outcomes) -> None: + self.calls = 0 + self._outcomes = list(outcomes) + self._last = outcomes[-1] if outcomes else None + + async def get_snapshot(self): + self.calls += 1 + if not self._outcomes: + # Re-serve the last outcome: setup's first refresh and the + # test's explicit pass both poll the same scripted state. + outcome = self._last + else: + outcome = self._outcomes.pop(0) + self._last = outcome + if isinstance(outcome, BaseException): + raise outcome + return outcome + + +#: A fixture panel snapshot (the documented Feature 16 route shape): +#: two children — Ada owes one, owes none of it yet, is away today with +#: a returns date; Bo completed his only quest and is present. +FIXTURE_SNAPSHOT = { + "today_iso": _today_iso(), + "cycle_day": 3, + "children": [ + { + "child_id": 1, + "child_name": "Ada", + "present": False, + "next_present": "2026-09-24", + "due_today": 2, + "completed_today": 1, + "remaining_today": 1, + "completion_pct": 50, + "instances": [ + { + "id": 7, + "definition_id": 3, + "child_id": 1, + "title": "Brush teeth", + "icon": "🦷", + "window": "morning", + "due_time": "08:00", + "state": "completed", + "overdue": False, + "completed_at": f"{_today_iso()}T07:55:00+00:00", + "on_time": True, + }, + { + "id": 8, + "definition_id": 3, + "child_id": 1, + "title": "Pack bag", + "icon": None, + "window": "morning", + "due_time": "10:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + }, + ], + }, + { + "child_id": 2, + "child_name": "Bo", + "present": True, + "next_present": None, + "due_today": 1, + "completed_today": 1, + "remaining_today": 0, + "completion_pct": 100, + "instances": [ + { + "id": 9, + "definition_id": 4, + "child_id": 2, + "title": "Make bed", + "icon": "🛏", + "window": "morning", + "due_time": "07:30", + "state": "completed", + "overdue": False, + "completed_at": f"{_today_iso()}T07:31:00+00:00", + "on_time": True, + } + ], + }, + ], +} + + +async def _wire_entry(hass, make_entry, client, options=None): + """Build an entry whose coordinator uses the scripted API client. + + The client is registered with the conftest override BEFORE setup: + the first refresh at setup already polls the snapshot, so the + platform setup sees real data and creates the whole entity set. + """ + from conftest import set_coordinator_client + + entry = wire_entry_to_registry( + make_entry( + options=options, + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}, + ), + hass.registry, ) - end = ( - datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - await materialize(database, today, end, today=datetime.date.today()) - - -async def test_coordinator_snapshot_counts_and_states(hass, make_entry) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) + set_coordinator_client(entry, client) assert await async_setup_entry(hass, entry) is True coordinator = entry.runtime_data.coordinator - assert coordinator is not None - database = entry.runtime_data.database - await _seed_day(database) + assert coordinator.api_client is client + return entry, coordinator + +async def test_coordinator_builds_snapshot_from_fixture(hass, make_entry) -> None: + """One refresh = one snapshot GET; the fixture's values land on the + snapshot the entities read, per child and household-wide.""" + client = StubSnapshotClient(FIXTURE_SNAPSHOT) + _entry, coordinator = await _wire_entry(hass, make_entry, client) + calls_after_setup = client.calls snapshot = await coordinator._async_update_data() + + assert client.calls == calls_after_setup + 1 assert snapshot.today_iso == _today_iso() - assert len(snapshot.children) == 2, "inactive child must be absent" - by_name = {child.child_name: child for child in snapshot.children} - ada = by_name["Ada"] - assert ada.due_today == 1 - assert ada.completed_today == 0 + assert snapshot.cycle_day == 3 + assert len(snapshot.children) == 2 + + ada, bo = snapshot.children + assert (ada.child_id, ada.child_name) == (1, "Ada") + assert ada.due_today == 2 + assert ada.completed_today == 1 assert ada.remaining_today == 1 - assert ada.completion_pct == 0 - instance = ada.instances[0] - assert instance.state == "open" - assert instance.overdue is False - assert instance.title == "Brush teeth" - assert instance.window == "morning" - assert ada.present is True, "no schedule means present every day" - assert ada.next_present is None, "present child has no returns date" - assert snapshot.cycle_day == 0, "no schedule anywhere" + assert ada.completion_pct == 50 + assert ada.present is False + assert ada.next_present == "2026-09-24" + + done_view, open_view = ada.instances + assert done_view.instance_id == 7 + assert done_view.title == "Brush teeth" + assert done_view.icon == "🦷" + assert done_view.window == "morning" + assert done_view.due_time == "08:00" + assert done_view.due_date == _today_iso() + # The API's "completed" maps back to the core view's "done". + assert done_view.state == "done" + assert done_view.overdue is False + assert done_view.completed_at == f"{_today_iso()}T07:55:00+00:00" + assert done_view.was_on_time is True + + assert open_view.state == "open" + assert open_view.overdue is True + assert open_view.completed_at is None + assert open_view.was_on_time is None + + assert (bo.child_id, bo.child_name) == (2, "Bo") + assert bo.due_today == 1 + assert bo.completed_today == 1 + assert bo.remaining_today == 0 + assert bo.completion_pct == 100 + assert bo.present is True + assert bo.next_present is None + + # Household rollups: the sums the household sensors compute. + assert sum(child.due_today for child in snapshot.children) == 3 + assert sum(child.completed_today for child in snapshot.children) == 2 + assert sum(child.remaining_today for child in snapshot.children) == 1 -async def test_coordinator_next_present_for_away_child( - hass, make_entry +async def test_coordinator_refresh_publishes_to_listeners(hass, make_entry) -> None: + """The refresh pass notifies listeners and reports success — the + ONE refresh pass every entity reads from.""" + client = StubSnapshotClient(FIXTURE_SNAPSHOT) + _entry, coordinator = await _wire_entry(hass, make_entry, client) + called = [] + coordinator.async_add_listener(lambda: called.append("entity")) + await coordinator.async_refresh() + assert called == ["entity"] + assert coordinator.last_update_success is True + assert coordinator.data is not None + assert coordinator.data.children[0].child_name == "Ada" + + +async def test_instance_payload_shape_is_unchanged(hass, make_entry) -> None: + """The per-child instance attribute payload the due-today sensor + emits keeps its exact documented shape, and admin_instances now + equals instances (the API omits missed rows — D-009; the admin + surface for missed quests is the PWA).""" + client = StubSnapshotClient(FIXTURE_SNAPSHOT) + _entry, coordinator = await _wire_entry(hass, make_entry, client) + snapshot = await coordinator._async_update_data() + ada = snapshot.children[0] + + panel = instance_payload(ada.instances, include_missed=False) + assert panel == [ + { + "id": 7, + "definition_id": 3, + "child_id": 1, + "title": "Brush teeth", + "icon": "🦷", + "window": "morning", + "due_time": "08:00", + "state": "completed", + "overdue": False, + "completed_at": f"{_today_iso()}T07:55:00+00:00", + "on_time": True, + }, + { + "id": 8, + "definition_id": 3, + "child_id": 1, + "title": "Pack bag", + "icon": None, + "window": "morning", + "due_time": "10:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + }, + ] + # The API payload omits missed rows, so include_missed cannot + # re-add them: admin_instances == instances, byte for byte. + assert instance_payload(ada.instances, include_missed=True) == panel + + +async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> None: + """The registered sensors and binary sensors read the rebuilt + snapshot: entity values come from the fixture, with the SAME + entity_ids as ever.""" + client = StubSnapshotClient(FIXTURE_SNAPSHOT) + entry, coordinator = await _wire_entry(hass, make_entry, client) + await coordinator.async_refresh() + + ada_due = hass.entities[f"{DOMAIN}_child_1_quests_due_today"] + assert ada_due.native_value == 2 + attributes = ada_due.extra_state_attributes + assert attributes["child_id"] == 1 + assert attributes["child_name"] == "Ada" + assert attributes["present"] is False + assert [row["id"] for row in attributes["instances"]] == [7, 8] + + assert ( + hass.entities[f"{DOMAIN}_child_1_quests_completed_today"].native_value + == 1 + ) + assert ( + hass.entities[f"{DOMAIN}_child_1_quests_remaining_today"].native_value + == 1 + ) + assert ( + hass.entities[f"{DOMAIN}_child_1_completion_pct_today"].native_value + == 50 + ) + assert hass.entities[f"{DOMAIN}_child_1_next_quest"].native_value == ( + "Pack bag" + ) + # Ada owes one still: not all done; away today. + assert hass.entities[f"{DOMAIN}_child_1_all_done"].is_on is False + assert hass.entities[f"{DOMAIN}_child_1_present_today"].is_on is False + + assert ( + hass.entities[f"{DOMAIN}_child_2_completion_pct_today"].native_value + == 100 + ) + # Bo had a quest and owes none of it: all done, present today. + assert hass.entities[f"{DOMAIN}_child_2_all_done"].is_on is True + assert hass.entities[f"{DOMAIN}_child_2_present_today"].is_on is True + bo_attributes = hass.entities[ + f"{DOMAIN}_child_2_present_today" + ].extra_state_attributes + assert bo_attributes["next_present"] is None + + assert ( + hass.entities[f"{DOMAIN}_household_quests_due_today"].native_value == 3 + ) + assert ( + hass.entities[f"{DOMAIN}_household_quests_completed_today"].native_value + == 2 + ) + cycle = hass.entities[f"{DOMAIN}_cycle_day"] + assert cycle.native_value == 3 + assert cycle.extra_state_attributes == { + "cycle_day": 3, + "today": _today_iso(), + } + + await async_unload_entry(hass, entry) + + +async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> None: + """A failed fetch (transport failure or non-2xx — both typed) raises + out of the refresh pass: last_update_success goes False (entities + unavailable), data keeps its last good value, and the typed error is + logged, never swallowed.""" + error = NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", + status=503, + ) + # The stub serves the good snapshot for setup's first refresh, then + # the scripted failure. + client = StubSnapshotClient(FIXTURE_SNAPSHOT, error) + _entry, coordinator = await _wire_entry(hass, make_entry, client) + # First refresh (at setup) served the good snapshot. + assert coordinator.data is not None + assert coordinator.last_update_success is True + + # The next pass hits the scripted failure: it raises out of the + # refresh, marks the entities unavailable, and keeps the last GOOD + # snapshot as data. + with pytest.raises(NestQuestApiError): + await coordinator.async_refresh() + assert coordinator.last_update_success is False + assert coordinator.data.children[0].child_name == "Ada" + + +async def test_coordinator_unconfigured_api_does_not_crash_setup( + hass, make_entry, monkeypatch ) -> None: - """A child absent today carries the ISO date of their next present - day; an always-absent pattern carries None instead.""" + """An entry with no panel token set up fine: the coordinator gets + the typed-error stub, the first refresh fails cleanly (unavailable + entities), and setup does NOT crash.""" + # Restore the production factory: the conftest default would serve + # the local snapshot, but this test exercises the token guard. + import custom_components.nestquest as nq + + monkeypatch.setattr( + nq, "coordinator_client_from_entry", coordinator_client_from_entry + ) entry = wire_entry_to_registry(make_entry(), hass.registry) assert await async_setup_entry(hass, entry) is True coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - await create_child(database, "Ada") - ada = (await list_children(database))[0] - today = datetime.date.today() - pattern = ",".join( - str(day) for day in range(7) if day != today.weekday() - ) - await PresenceSchedulesDao(database).upsert_by_child( - ada.id, 1, today.isoformat(), pattern - ) - await create_child(database, "Bo") - bo = (await list_children(database))[1] - await PresenceSchedulesDao(database).upsert_by_child( - bo.id, 1, today.isoformat(), "" - ) - snapshot = await coordinator._async_update_data() - by_name = {child.child_name: child for child in snapshot.children} - assert by_name["Ada"].present is False - assert by_name["Ada"].next_present == ( - today + datetime.timedelta(days=1) - ).isoformat() - assert by_name["Bo"].present is False - assert by_name["Bo"].next_present is None + from custom_components.nestquest.coordinator import _UnconfiguredApiClient + + assert isinstance(coordinator.api_client, _UnconfiguredApiClient) + # A real NestQuestApiClient was never built (it would ValueError). + assert not isinstance(coordinator.api_client, NestQuestApiClient) + assert coordinator.last_update_success is False + assert coordinator.data is None + with pytest.raises(NestQuestApiError, match="not configured"): + await coordinator.async_refresh() -async def test_coordinator_reflects_completion_and_uncompletion( +async def test_coordinator_configured_token_builds_real_client( hass, make_entry ) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) + """An entry WITH a panel token builds the real client through the + production factory (a stub transport injected via session_factory, + the mock-only harness having no aiohttp module to hand in), and the + coordinator's refresh polls the snapshot route end to end.""" + import json as json_module + + class _OneShotSession: + """A session-shaped stub answering every request with the fixture.""" + + def __init__(self, payload) -> None: + self._body = json_module.dumps(payload) + + def request(self, method, url, **kwargs): + body = self._body + + class _Response: + def __init__(self) -> None: + self.status = 200 + + async def text(self): + return body + + class _Context: + async def __aenter__(self): + return _Response() + + async def __aexit__(self, *exc): + return False + + return _Context() + + def _session_factory(_hass): + return _OneShotSession(FIXTURE_SNAPSHOT) + + entry = wire_entry_to_registry( + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} + ), + hass.registry, + ) + real_client = coordinator_client_from_entry( + hass, entry, session_factory=_session_factory + ) + assert isinstance(real_client, NestQuestApiClient) + + set_coordinator_client(entry, real_client) assert await async_setup_entry(hass, entry) is True coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - await _seed_day(database) - ada = ( - await coordinator._async_update_data() - ).children[0] - instance_id = ada.instances[0].instance_id - - await complete_instance( - database, - instance_id, - actor_source="panel", - actor_child_id=ada.child_id, - ) - snapshot = await coordinator._async_update_data() - child = snapshot.children[0] - assert child.completed_today == 1 - assert child.remaining_today == 0 - assert child.completion_pct == 100 - view = child.instances[0] - assert view.state == "done" - assert view.completed_at is not None - assert view.was_on_time is not None - - from custom_components.nestquest.completion import uncomplete_instance - - await uncomplete_instance( - database, instance_id, actor_source="user", actor_user_id="admin-1" - ) - snapshot = await coordinator._async_update_data() - child = snapshot.children[0] - assert child.completed_today == 0 - assert child.remaining_today == 1 - assert child.instances[0].state == "open" - - -async def test_coordinator_zero_quests_reports_full_percentage( - hass, make_entry -) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - coordinator = entry.runtime_data.coordinator - await create_child(entry.runtime_data.database, "Ada") - snapshot = await coordinator._async_update_data() - child = snapshot.children[0] - assert child.due_today == 0 - assert child.completion_pct == 100, "zero quests is a perfect day" - assert child.instances == () - - -async def test_coordinator_cycle_day_from_first_scheduled_child( - hass, make_entry -) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - await create_child(database, "Ada") - ada = (await list_children(database))[0] - today = datetime.date.today() - # A 2-week cycle anchored 3 days ago: today is day 4. - anchor = today - datetime.timedelta(days=3) - await PresenceSchedulesDao(database).upsert_by_child( - ada.id, 2, anchor.isoformat(), "0,1,2,3,4,5,6|" - ) - snapshot = await coordinator._async_update_data() - assert snapshot.cycle_day == 4 - - -async def test_coordinator_presence_resolves_schedule_and_override( - hass, make_entry -) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - await create_child(database, "Ada") - ada = (await list_children(database))[0] - today = datetime.date.today() - # Scheduled absent today (empty weekday set for this cycle week). - await PresenceSchedulesDao(database).upsert_by_child( - ada.id, 1, today.isoformat(), "" - ) - snapshot = await coordinator._async_update_data() - assert snapshot.children[0].present is False - - # An override beats the pattern. - from custom_components.nestquest.dao_presence import ( - PresenceOverridesDao, - ) - - await PresenceOverridesDao(database).create( - ada.id, today.isoformat(), today.isoformat(), True - ) - snapshot = await coordinator._async_update_data() - assert snapshot.children[0].present is True - - -async def test_coordinator_next_present_honours_future_override( - hass, make_entry -) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - await create_child(database, "Ada") - ada = (await list_children(database))[0] - today = datetime.date.today() - tomorrow = today + datetime.timedelta(days=1) - # Scheduled present tomorrow (single-day cycle, weekday set set below - # covers every day), but overridden absent tomorrow. - await PresenceSchedulesDao(database).upsert_by_child( - ada.id, 1, today.isoformat(), "0,1,2,3,4,5,6" - ) - from custom_components.nestquest.dao_presence import ( - PresenceOverridesDao, - ) - - await PresenceOverridesDao(database).create( - ada.id, tomorrow.isoformat(), tomorrow.isoformat(), False - ) - snapshot = await coordinator._async_update_data() - child = snapshot.children[0] - assert child.present is True - assert child.next_present != tomorrow.isoformat(), ( - "a future absent override must remove tomorrow from the preview" - ) - - -async def test_coordinator_derives_missed_for_past_due_open( - hass, make_entry -) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - coordinator = entry.runtime_data.coordinator - snapshot = await coordinator._async_update_data() - # No children: empty but valid snapshot. - assert snapshot.children == () + assert isinstance(coordinator.api_client, NestQuestApiClient) + # The real client polls the stub transport; the snapshot lands. + await coordinator.async_refresh() + assert coordinator.data.children[0].child_name == "Ada" async def test_setup_creates_coordinator_with_configured_interval( @@ -270,12 +459,9 @@ async def test_setup_creates_coordinator_with_configured_interval( coordinator = entry.runtime_data.coordinator assert isinstance(coordinator, NestQuestCoordinator) assert coordinator.update_interval == datetime.timedelta(seconds=60) - assert coordinator.data is not None, "first refresh ran at setup" -async def test_setup_defaults_to_five_minute_interval( - hass, make_entry -) -> None: +async def test_setup_defaults_to_five_minute_interval(hass, make_entry) -> None: entry = wire_entry_to_registry(make_entry(), hass.registry) assert await async_setup_entry(hass, entry) is True coordinator = entry.runtime_data.coordinator @@ -298,24 +484,11 @@ async def test_unload_shuts_coordinator_down(hass, make_entry) -> None: assert torn_down == [coordinator] -async def test_coordinator_notifies_listeners_on_refresh( - hass, make_entry -) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - coordinator = entry.runtime_data.coordinator - called = [] - coordinator.async_add_listener(lambda: called.append("entity")) - await coordinator.async_refresh() - assert called == ["entity"] - assert coordinator.last_update_success is True - - async def test_failed_first_refresh_shuts_coordinator_down( hass, make_entry, monkeypatch ) -> None: """A setup failure after coordinator creation must not leak its - listeners against the database the except path closes.""" + listeners.""" from custom_components.nestquest import async_setup_entry import custom_components.nestquest.coordinator as coordinator_module @@ -344,3 +517,14 @@ async def test_failed_first_refresh_shuts_coordinator_down( await async_setup_entry(hass, entry) assert shutdown_calls == [entry.entry_id] assert getattr(entry, "runtime_data", None) is None + + +async def test_snapshot_from_api_payload_empty_household() -> None: + """An empty household (no children) is a valid snapshot: empty + children tuple, cycle day passes through.""" + snapshot = snapshot_from_api_payload( + {"today_iso": "2026-09-23", "cycle_day": 0, "children": []} + ) + assert snapshot.children == () + assert snapshot.cycle_day == 0 + assert snapshot.today_iso == "2026-09-23" diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py index e795989..f704bc6 100644 --- a/tests/test_core_snapshot.py +++ b/tests/test_core_snapshot.py @@ -123,9 +123,11 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> database = entry.runtime_data.database settings = entry.runtime_data.settings - # Single clock read for the seed/snapshot anchor (no midnight skew). - today = coordinator._local_now().date() + # Single clock read for the seed/snapshot anchor (no midnight skew); + # the coordinator no longer owns a clock read (the API serves the + # snapshot), so the test resolves the HA-local zone directly. time_zone = ZoneInfo(hass.config.time_zone) + today = datetime.datetime.now(time_zone).date() # Pin the build instant at 12:00 local: past Ada's 10:00 due time # (overdue) and before Bo's 17:00, and the completion moment for Bo. pinned_now = datetime.datetime( @@ -294,13 +296,38 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> == "missed" ) - # REQUIRED wrapper tie-in: the coordinator's _async_update_data is a - # thin WRAPPER over build_snapshot, so pinning its clock to the SAME - # pinned_now the builder was called with must reproduce the builder's - # snapshot byte-for-byte. The dataclasses are frozen, so == is a full - # deep compare — a wrapper that mis-passed today vs now, or stopped - # calling the builder, would diverge here even though the literal - # assertions above still pass. - monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now) + # REQUIRED wrapper tie-in: since Feature 18 the coordinator's + # refresh polls the snapshot through its API client, so pinning the + # client to serve the payload of the SAME snapshot built with + # pinned_now must reproduce the builder's snapshot byte-for-byte. + # The dataclasses are frozen, so == is a full deep compare — a + # reconstruction that dropped or reshaped a field would diverge + # here even though the literal assertions above still pass. + from conftest import set_coordinator_client + + class _PinnedPayloadClient: + async def get_snapshot(self): + return { + "today_iso": built.today_iso, + "cycle_day": built.cycle_day, + "children": [ + { + "child_id": child.child_id, + "child_name": child.child_name, + "present": child.present, + "next_present": child.next_present, + "due_today": child.due_today, + "completed_today": child.completed_today, + "remaining_today": child.remaining_today, + "completion_pct": child.completion_pct, + "instances": instance_payload( + child.instances, include_missed=False + ), + } + for child in built.children + ], + } + + set_coordinator_client(entry, _PinnedPayloadClient()) await coordinator.async_refresh() assert coordinator.data == built diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py index f1e8757..0452966 100644 --- a/tests/test_entity_matrix.py +++ b/tests/test_entity_matrix.py @@ -180,9 +180,12 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: assert _sensor(hass, ada.id, "all_done").is_on is False assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1 - # --- The instances payload omits missed instances while - # admin_instances includes them (D-009): pin the derive clock - # forward in the TEST ONLY so today's open instances read missed. + # --- The instances payload omits missed instances (D-009) — and + # since Feature 18 the snapshot comes from the API panel route, + # which omits missed rows ENTIRELY, so admin_instances cannot + # carry them either (the admin surface for missed quests is the + # PWA, reading the API directly): pin the derive clock forward in + # the TEST ONLY so today's open instances read missed. original_refresh = coordinator._async_update_data async def _future_dated_refresh(): @@ -203,9 +206,9 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: await coordinator.async_refresh() attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes assert attributes["instances"] == [], "panel payload omits missed" - assert len(attributes["admin_instances"]) == 2 - assert all( - entry["state"] == "missed" for entry in attributes["admin_instances"] + assert attributes["admin_instances"] == [], ( + "the API payload omits missed rows, so the admin payload " + "carries none either (the PWA is the admin surface now)" ) coordinator._async_update_data = original_refresh await coordinator.async_refresh() diff --git a/tests/test_sensors.py b/tests/test_sensors.py index dcacaea..c451db4 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -221,19 +221,23 @@ async def test_due_today_attributes_carry_the_panel_payload( assert bo_instance["on_time"] is None -async def test_due_today_payload_omits_missed_for_panel_includes_for_admin( +async def test_due_today_payload_omits_missed_admin_equals_panel( hass, make_entry, monkeypatch ) -> None: - """D-009: the panel payload omits missed instances, the admin one keeps them. + """D-009: the panel payload omits missed instances. - A missed instance is derived (``due_date`` before the snapshot's - "today"), never stored. The coordinator's day listing is keyed on - ``_local_now``'s date, so pinning the clock forward would move the - listing window off today's rows entirely; the pinned FUTURE clock - is therefore applied at the ``derive_state`` boundary instead — - today's open instances then read as missed through the same - derivation rule, without back-dating instance rows past the DAO's - no-past guard or touching append-only completion events. + Since Feature 18 the snapshot comes from the API panel route, + which omits missed rows entirely (the admin surface for missed + quests is the PWA, reading the API directly). The entity payload + therefore cannot carry missed rows any more: ``admin_instances`` + equals ``instances`` — open and completed rows only. A missed + instance is still derived locally (``due_date`` before the + snapshot's "today"); the pinned-FUTURE clock at the + ``derive_state`` boundary makes today's open instances read + missed through the same derivation rule, and this test pins the + change: neither payload carries them, and the count rollups + (precomputed server-side from the full day) are unaffected here + because the API counts arrive per the route's own build. """ entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( hass, make_entry @@ -256,12 +260,10 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin( assert attributes["instances"] == [], ( "missed instances are omitted from the panel payload" ) - [missed] = attributes["admin_instances"] - assert missed["state"] == "missed" - assert missed["completed_at"] is None - # Counts still roll the missed instance up as not-completed. - assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0 - assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1 + assert attributes["admin_instances"] == [], ( + "the API payload omits missed rows, so the admin payload " + "carries none either (the PWA is the admin surface now)" + ) @pytest.mark.parametrize("kind", SENSOR_KINDS) -- 2.55.0 From 536caf91e42fc01b9c13060bdab0530ab9dc2ce4 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 06:26:29 -0400 Subject: [PATCH 2/2] Fix review findings: first-refresh handling, stale runtime record, P3s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1: the first-refresh error handling never fired against real Home Assistant. Real DataUpdateCoordinator.async_config_entry_first_refresh converts ANY refresh failure into ConfigEntryNotReady (the original exception survives only as __cause__), so `except NestQuestApiError` could never match: the failure fell through to the outer except block, unwound listeners, closed the DB and re-raised, and async_forward_entry_setups never ran — no entities at all. Two-sided fix: _async_update_data now converts every typed NestQuestApiError (unconfigured stub, transport failure, non-2xx) into homeassistant.helpers.update_coordinator.UpdateFailed — the exception HA's coordinator machinery records as a failed pass (last_update_success=False, data=None) — and setup catches ConfigEntryNotReady around the first refresh instead, so an unconfigured API or failed fetch keeps setup alive with unavailable entities while everything else still unwinds. The conftest DataUpdateCoordinator stand-in now reproduces HA's real contract (async_refresh records a failure instead of raising; first_refresh wraps it in ConfigEntryNotReady) and gains an UpdateFailed stand-in, so this class of bug is caught by the suite — verified by reverting the catch, which fails both unconfigured-API and failed-fetch tests. P1: a stale hass.data[DOMAIN][entry.entry_id] survived a failed setup. The runtime record registers in hass.data before the first refresh and platform forward, but the except cleanup only cleared entry.runtime_data; after any failure in that window a later unload picked up the stale, already-torn-down record and re-invoked the already-consumed update-listener remover (ValueError). The except block now also pops the hass.data entry, and test_setup_failure_after_registration_shuts_coordinator_down (reworked from the fiction-based first-refresh trigger: a failed first refresh is tolerated setup, not an unwind) proves a failed setup followed by an unload does not raise and leaves no residue. P3: dropped the function-local re-import of NestQuestApiClient in coordinator_client_from_entry (module-level import already covers it), and the unused coordinator_client_from_entry alias in the conftest fixture. P3: test_due_today_payload_omits_missed_admin_equals_panel dropped the missed-instance count assertions in the Feature 18 rewrite; restored quests_completed_today == 0 / quests_remaining_today == 1 so the missed-derivation + counts pairing stays verified (the counts arrive precomputed and still roll the full day). --- custom_components/nestquest/__init__.py | 17 ++- custom_components/nestquest/coordinator.py | 57 +++++++--- tests/conftest.py | 55 ++++++++-- tests/test_coordinator.py | 117 +++++++++++++++++---- tests/test_sensors.py | 5 + 5 files changed, 205 insertions(+), 46 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index c05e392..bbcb3ad 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -16,7 +16,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.event import async_track_time_change -from .api_client import NestQuestApiError from .const import ( CONF_ADMIN_USER_IDS, CONF_UPDATE_INTERVAL, @@ -526,10 +525,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # (unconfigured token, API service down) does NOT crash setup: # the coordinator reports last_update_success=False, entities # come up unavailable, and HA's standard retry cycle applies - # once the API is reachable. Any non-API error still raises. + # once the API is reachable. HA's real + # ``async_config_entry_first_refresh`` wraps ANY failed update + # pass in ConfigEntryNotReady — the original error survives + # only as ``__cause__``, never as the raised exception — so the + # catch keys on ConfigEntryNotReady, NOT on the typed API + # error. Everything else (a platform-forward failure, + # cancellation) still unwinds through the except block below. try: await coordinator.async_config_entry_first_refresh() - except NestQuestApiError: + except ConfigEntryNotReady: pass await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) except BaseException: @@ -564,6 +569,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # closes the already-closed database through the stale record. if getattr(entry, "runtime_data", None) is not None: entry.runtime_data = None + # The runtime record was ALSO registered in hass.data before + # the first refresh: remove it here on ANY failure, or a later + # unload picks up the stale, already-torn-down record + # (hass.data wins over entry.runtime_data there) and + # re-invokes the already-consumed update-listener remover. + hass.data.get(DOMAIN, {}).pop(entry.entry_id, None) await database.close() raise assert remove_update_listener is not None diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index bc7596a..1d634e1 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -32,11 +32,17 @@ Failure handling: constructing the client fails fast (ValueError) when the entry has no panel token configured, and a fetch failure raises the typed :class:`~.api_client.NestQuestApiError`. Neither crashes integration setup: the constructor logs a clear message and hands back -a client-shaped stub whose refresh raises the same typed error, so the -coordinator's refresh pass marks the entities unavailable -(``last_update_success = False``, ``data = None``) — exactly how a HA -``DataUpdateCoordinator`` surfaces a failed update. The typed error is -never swallowed silently: it is logged with its message and chained. +a client-shaped stub whose refresh raises the same typed error, and +``_async_update_data`` converts every typed error into +:class:`~homeassistant.helpers.update_coordinator.UpdateFailed` — the +exception a HA ``DataUpdateCoordinator`` records as a failed pass +(``last_update_success = False``, ``data = None``), so the entities +come up unavailable. The typed error is never swallowed silently: it +is logged with its message and chained into the UpdateFailed. (The +conversion matters at setup: HA's real +``async_config_entry_first_refresh`` wraps any failed pass in +``ConfigEntryNotReady``, so a setup that keys on the original +exception type would never match it.) """ from __future__ import annotations @@ -45,7 +51,10 @@ import datetime from typing import Any from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config from .const import ( @@ -150,9 +159,11 @@ class _UnconfiguredApiClient: yet must not crash integration setup, so the coordinator swaps in this stub: every refresh raises the SAME typed :class:`~.api_client.NestQuestApiError` a transport failure would, - carrying a clear "API not configured" message, so the coordinator - marks the entities unavailable and the HA standard retry cycle - applies. The error is logged, never silent. + carrying a clear "API not configured" message; + :meth:`NestQuestCoordinator._async_update_data` converts it to + :class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, so + the coordinator marks the entities unavailable and the HA standard + retry cycle applies. The error is logged, never silent. """ __slots__ = () @@ -171,10 +182,15 @@ class NestQuestCoordinator(DataUpdateCoordinator): One refresh pass = one ``GET /api/v1/panel/snapshot`` through the API client, reconstructed into the core snapshot dataclasses every entity reads. A failed pass (unconfigured API, transport failure, - non-2xx response) raises the typed error out of - ``_async_update_data``, so the coordinator's ``data`` keeps its last - good value while ``last_update_success`` goes False — HA marks the - entities unavailable until a later pass succeeds. + non-2xx response) raises the typed error out of the client; + ``_async_update_data`` converts it to + :class:`~homeassistant.helpers.update_coordinator.UpdateFailed`, the + exception HA's coordinator machinery records as a failed pass: the + coordinator's ``data`` keeps its last good value while + ``last_update_success`` goes False — HA marks the entities + unavailable until a later pass succeeds. An unexpected + non-typed error still propagates out of ``_async_update_data`` + and is logged (with its traceback) by HA's machinery, the same way. Forced refreshes (the service paths pushing an immediate update after a completion) are SERIALIZED per entry: overlapping @@ -218,13 +234,24 @@ class NestQuestCoordinator(DataUpdateCoordinator): await super().async_refresh() async def _async_update_data(self) -> NestQuestSnapshot: + """One refresh pass: fetch the panel snapshot and rebuild it. + + A typed API failure (unconfigured token stub, transport + failure, non-2xx response) is converted to + :class:`~homeassistant.helpers.update_coordinator.UpdateFailed` + — the exception HA's coordinator machinery records as a failed + pass (``last_update_success = False``, ``data`` unchanged) so + entities go unavailable and the setup-time first refresh + arrives as ``ConfigEntryNotReady`` — with the typed error + logged and chained. Anything else still propagates. + """ try: payload = await self.api_client.get_snapshot() except NestQuestApiError as err: LOGGER.error( "NestQuest coordinator refresh failed: %s", err ) - raise + raise UpdateFailed(str(err)) from err return snapshot_from_api_payload(payload) @@ -247,8 +274,6 @@ def coordinator_client_from_entry( inject a stub transport without importing aiohttp — the mock-only test harness has no ``homeassistant.helpers.aiohttp_client``. """ - from custom_components.nestquest.api_client import NestQuestApiClient - base_url, panel_token = resolve_api_config(entry) if not isinstance(panel_token, str) or not panel_token.strip(): LOGGER.warning( diff --git a/tests/conftest.py b/tests/conftest.py index 86b9b28..d7e836e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -367,6 +367,15 @@ class BinarySensorEntity(Entity): return self._attr_device_class +class UpdateFailed(HomeAssistantError): + """Stand-in mirroring homeassistant.helpers.update_coordinator.UpdateFailed. + + The exception an update pass raises to report a failed fetch: the + coordinator records ``last_update_success = False`` instead of + propagating it out of the refresh. + """ + + class DataUpdateCoordinator: """Functional stand-in for HA's DataUpdateCoordinator. @@ -375,8 +384,20 @@ class DataUpdateCoordinator: ``async_add_listener``/``async_update_listeners`` for entity subscription, ``async_refresh``/``async_config_entry_first_refresh`` driving the subclass's ``_async_update_data``, ``last_update_success``, - and ``data``. Interval scheduling is NOT simulated (no real clock - ticks in tests) — tests drive refreshes directly. + ``last_exception``, and ``data``. Interval scheduling is NOT + simulated (no real clock ticks in tests) — tests drive refreshes + directly. + + The failure contract mirrors real HA exactly (it is the trap this + harness once hid): an update failure NEVER propagates out of + ``async_refresh`` — the coordinator records + ``last_update_success = False``, keeps its last good ``data``, and + still notifies listeners so entities re-render unavailable — and + ``async_config_entry_first_refresh`` wraps ANY failed pass in + :class:`ConfigEntryNotReady`, with the original error only + surviving as ``__cause__``. A stand-in that re-raised the raw + update error would let setup code key on the original exception + type — a clause real HA can never reach. """ def __init__(self, hass, logger, *, name=None, update_interval=None): @@ -386,6 +407,7 @@ class DataUpdateCoordinator: self.update_interval = update_interval self.data = None self.last_update_success = False + self.last_exception = None self._listeners = [] def async_add_listener(self, update_callback, context=None): @@ -405,18 +427,35 @@ class DataUpdateCoordinator: callback() async def async_refresh(self): - """Run one update pass and notify listeners.""" + """Run one update pass and notify listeners. + + Mirrors HA: a failed update pass is RECORDED, never raised — + ``last_update_success`` goes False, the exception lands on + ``last_exception``, and listeners still fire; only cancellation + propagates. + """ try: self.data = await self._async_update_data() self.last_update_success = True - except BaseException: + except Exception as err: self.last_update_success = False - raise + self.last_exception = err self.async_update_listeners() async def async_config_entry_first_refresh(self): - """First refresh at config-entry setup (alias of refresh).""" + """First refresh at config-entry setup, per HA's real contract. + + HA's real ``async_config_entry_first_refresh`` wraps ANY failed + update pass in ``ConfigEntryNotReady`` — the original error + survives only as ``__cause__`` — so setup code that must + tolerate a failed first refresh keys on ConfigEntryNotReady, + never on the original exception type. + """ await self.async_refresh() + if not self.last_update_success: + raise ConfigEntryNotReady( + f"error fetching {self.name} data: {self.last_exception}" + ) from self.last_exception async def async_shutdown(self): """Release listeners on teardown.""" @@ -426,6 +465,7 @@ class DataUpdateCoordinator: _update_coordinator_mock = _ha_mock("homeassistant.helpers.update_coordinator") _update_coordinator_mock.DataUpdateCoordinator = DataUpdateCoordinator _update_coordinator_mock.CoordinatorEntity = CoordinatorEntity +_update_coordinator_mock.UpdateFailed = UpdateFailed _entity_mock = _ha_mock("homeassistant.helpers.entity") _entity_mock.DeviceInfo = DeviceInfo @@ -787,9 +827,6 @@ def coordinator_serves_local_snapshot(): """ import custom_components.nestquest as nq_mod import custom_components.nestquest.coordinator as coordinator_module - from custom_components.nestquest.coordinator import ( - coordinator_client_from_entry as production_factory, - ) _COORDINATOR_CLIENT_OVERRIDES = ( coordinator_module._COORDINATOR_CLIENT_OVERRIDES diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 5b9b818..25dc0a0 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -33,6 +33,7 @@ from custom_components.nestquest.coordinator import ( snapshot_from_api_payload, ) from custom_components.nestquest.core.snapshot import instance_payload +from homeassistant.helpers.update_coordinator import UpdateFailed def _today_iso() -> str: @@ -340,10 +341,12 @@ async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> N async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> None: - """A failed fetch (transport failure or non-2xx — both typed) raises - out of the refresh pass: last_update_success goes False (entities - unavailable), data keeps its last good value, and the typed error is - logged, never swallowed.""" + """A failed fetch (transport failure or non-2xx — both typed) is a + FAILED refresh pass: the update pass converts the typed error to + UpdateFailed (the exception HA's coordinator machinery records as a + failed pass, chaining the typed error as ``__cause__``), the refresh + records the failure instead of raising, and data keeps its last + good value.""" error = NestQuestApiError( "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", status=503, @@ -356,13 +359,61 @@ async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> N assert coordinator.data is not None assert coordinator.last_update_success is True - # The next pass hits the scripted failure: it raises out of the - # refresh, marks the entities unavailable, and keeps the last GOOD - # snapshot as data. - with pytest.raises(NestQuestApiError): - await coordinator.async_refresh() + # The next pass hits the scripted failure: the update pass converts + # the typed error to UpdateFailed, with the typed error chained. + with pytest.raises(UpdateFailed, match="HTTP 503") as excinfo: + await coordinator._async_update_data() + assert isinstance(excinfo.value.__cause__, NestQuestApiError) + + # The refresh pass records the failure the HA way — it does NOT + # raise: last_update_success goes False, data keeps the last GOOD + # snapshot, and the failed pass lands on last_exception as the + # converted UpdateFailed with the typed error chained. + await coordinator.async_refresh() assert coordinator.last_update_success is False assert coordinator.data.children[0].child_name == "Ada" + assert isinstance(coordinator.last_exception, UpdateFailed) + assert isinstance(coordinator.last_exception.__cause__, NestQuestApiError) + + +async def test_failed_first_refresh_does_not_crash_setup( + hass, make_entry +) -> None: + """A failed fetch at the FIRST refresh (at setup) does not crash + setup: the coordinator reports last_update_success=False, the setup + catches the ConfigEntryNotReady that HA's real first refresh raises + for a failed pass, and the platforms still forward so the entities + come up unavailable.""" + # The stub fails every snapshot fetch (HTTP 503), including setup's + # first refresh. + client = StubSnapshotClient( + NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", + status=503, + ) + ) + entry = wire_entry_to_registry( + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} + ), + hass.registry, + ) + set_coordinator_client(entry, client) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert coordinator.api_client is client + # The failed first pass: the entities' surface stays unavailable. + assert coordinator.last_update_success is False + assert coordinator.data is None + # Setup survived the failed first refresh — the platforms were + # forwarded (the regression the review caught: a first-refresh + # failure used to abort setup before any entity was created). + assert hass.config_entries.forwarded_platforms == [ + "sensor", + "binary_sensor", + ] + # The unload after the failed-first-refresh setup stays clean. + assert await async_unload_entry(hass, entry) is True async def test_coordinator_unconfigured_api_does_not_crash_setup( @@ -386,10 +437,19 @@ async def test_coordinator_unconfigured_api_does_not_crash_setup( assert isinstance(coordinator.api_client, _UnconfiguredApiClient) # A real NestQuestApiClient was never built (it would ValueError). assert not isinstance(coordinator.api_client, NestQuestApiClient) + # The first refresh failed the HA way: the update pass converted + # the stub's typed error to UpdateFailed, the first refresh raised + # ConfigEntryNotReady, and setup tolerated it — platforms forwarded + # so the entities come up unavailable. assert coordinator.last_update_success is False assert coordinator.data is None - with pytest.raises(NestQuestApiError, match="not configured"): - await coordinator.async_refresh() + with pytest.raises(UpdateFailed, match="not configured"): + await coordinator._async_update_data() + assert hass.config_entries.forwarded_platforms == [ + "sensor", + "binary_sensor", + ] + assert await async_unload_entry(hass, entry) is True async def test_coordinator_configured_token_builds_real_client( @@ -484,11 +544,23 @@ async def test_unload_shuts_coordinator_down(hass, make_entry) -> None: assert torn_down == [coordinator] -async def test_failed_first_refresh_shuts_coordinator_down( +async def test_setup_failure_after_registration_shuts_coordinator_down( hass, make_entry, monkeypatch ) -> None: """A setup failure after coordinator creation must not leak its - listeners.""" + listeners or leave a stale runtime record behind. + + The runtime record registers in ``hass.data`` BEFORE the platform + forward, so a forward failure reaches the unwind with the record + already registered. The unwind must shut the coordinator down, + clear ``entry.runtime_data``, AND remove the stale ``hass.data`` + entry — otherwise a later unload picks up the stale, already-torn- + down record and re-invokes the already-consumed update-listener + remover, which raises. (A failed first refresh is NOT such a + failure: HA's real first refresh wraps it in ConfigEntryNotReady, + which setup deliberately tolerates so entities come up + unavailable.) + """ from custom_components.nestquest import async_setup_entry import custom_components.nestquest.coordinator as coordinator_module @@ -503,20 +575,29 @@ async def test_failed_first_refresh_shuts_coordinator_down( _record_shutdown, ) - async def _boom(self): - raise RuntimeError("refresh failed") + async def _boom(entry, platforms): + raise RuntimeError("platform forward failed") monkeypatch.setattr( - coordinator_module.NestQuestCoordinator, - "_async_update_data", + hass.config_entries, + "async_forward_entry_setups", _boom, ) entry = wire_entry_to_registry(make_entry(), hass.registry) - with pytest.raises(RuntimeError, match="refresh failed"): + with pytest.raises(RuntimeError, match="platform forward failed"): await async_setup_entry(hass, entry) assert shutdown_calls == [entry.entry_id] assert getattr(entry, "runtime_data", None) is None + # The stale hass.data registration was removed with the runtime + # record: the unload below finds nothing stale, must not raise, + # and must leave no residue behind. + assert await async_unload_entry(hass, entry) is True + assert DOMAIN not in hass.data + assert hass.data == {} + assert hass.registry.size == 0 + assert hass.time_change.size == 0 + assert entry.runtime_data is None async def test_snapshot_from_api_payload_empty_household() -> None: diff --git a/tests/test_sensors.py b/tests/test_sensors.py index c451db4..fa29adb 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -264,6 +264,11 @@ async def test_due_today_payload_omits_missed_admin_equals_panel( "the API payload omits missed rows, so the admin payload " "carries none either (the PWA is the admin surface now)" ) + # Counts still roll the missed instance up as not-completed: the + # counts arrive precomputed from the route's own build, which rolls + # the FULL day (the missed row included) into the numbers. + assert _sensor(hass, bo.id, "quests_completed_today").native_value == 0 + assert _sensor(hass, bo.id, "quests_remaining_today").native_value == 1 @pytest.mark.parametrize("kind", SENSOR_KINDS) -- 2.55.0