From 78fd75ec45b1d5acc03e151d5bb9ecbf0a030fc7 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 05:12:27 -0400 Subject: [PATCH 1/7] Task 37d5e806: API client module for the integration (Feature 18) (#166) --- custom_components/nestquest/api_client.py | 283 +++++++++++ custom_components/nestquest/core/const.py | 26 + custom_components/nestquest/options_flow.py | 43 ++ custom_components/nestquest/strings.json | 5 +- .../nestquest/translations/en.json | 5 +- tests/test_api_client.py | 450 ++++++++++++++++++ tests/test_options_flow.py | 118 ++++- 7 files changed, 920 insertions(+), 10 deletions(-) create mode 100644 custom_components/nestquest/api_client.py create mode 100644 tests/test_api_client.py diff --git a/custom_components/nestquest/api_client.py b/custom_components/nestquest/api_client.py new file mode 100644 index 0000000..1a7ed05 --- /dev/null +++ b/custom_components/nestquest/api_client.py @@ -0,0 +1,283 @@ +"""Panel-plane API client for the NestQuest integration (Feature 18). + +The integration talks to the NestQuest API service (Feature 16) over +HTTP instead of reading the database: this module is the ONE place that +speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's +household snapshot) and ``POST /api/v1/panel/instances/{id}/complete`` +(completion as the tapped child profile) — authenticated with the +static panel service token as a Bearer credential on every request. + +Transport-injected by construction: the constructor takes the base URL, +the panel token, and the HTTP session to use. The session speaks the +aiohttp ``ClientSession`` surface (``request(method, url, headers=..., +json=...)`` returning an async context manager whose response carries +``status`` and ``await text()``), so tests stub the transport with NO +network and NO aiohttp installed; production builds the client through +:func:`client_from_entry`, which hands in Home Assistant's shared +aiohttp session (``homeassistant.helpers.aiohttp_client +.async_get_clientsession``). The module itself imports NEITHER +homeassistant NOR aiohttp at runtime (aiohttp appears only behind +``TYPE_CHECKING`` for annotations), so the client is usable anywhere — +the HA coupling lives in the one production factory. + +Every failure is typed: a transport failure (connection refused, DNS +failure, timeout — the exchange never produced a response) and every +non-2xx HTTP response raise :class:`NestQuestApiError`. The error +carries the HTTP status where there is one (so 401 and 404 are +distinguishable at the call site) and ``None`` when there is not; the +original transport error is chained as ``__cause__`` — nothing is +swallowed. There are NO retries and the client logs nothing: refresh +cadence is the coordinator's concern (the next task), and the panel +token is a secret that must never reach a log line or an error +message — the error texts name the method, path, and status only. +""" +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from .const import ( + CONF_API_BASE_URL, + CONF_PANEL_TOKEN, + DEFAULT_API_BASE_URL, + DEFAULT_PANEL_TOKEN, +) + +if TYPE_CHECKING: + import aiohttp + +__all__ = [ + "COMPLETE_INSTANCE_PATH", + "DEFAULT_TIMEOUT_SECONDS", + "NestQuestApiError", + "NestQuestApiClient", + "SNAPSHOT_PATH", + "client_from_entry", + "is_valid_base_url", + "resolve_api_config", +] + +#: The panel plane's snapshot route (Feature 16). +SNAPSHOT_PATH = "/api/v1/panel/snapshot" + +#: The panel plane's completion route template (Feature 16); the +#: ``{instance_id}`` placeholder is filled per call. +COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete" + +#: Request timeout, in seconds. One attempt, one timeout: a hung API +#: call surfaces as a typed error for the coordinator to report, never +#: a retry loop. +DEFAULT_TIMEOUT_SECONDS = 10.0 + + +class NestQuestApiError(Exception): + """A failed NestQuest API exchange, raised for every failure mode. + + ``status`` carries the HTTP status when a response arrived (a + non-2xx response — 401 for a bad token, 404 for an unknown + instance) and is ``None`` when the exchange failed at the transport + level (connection refused, DNS failure, timeout) — so callers + distinguish the two with ``is_transport_error``. The original + transport error, where there is one, is chained as ``__cause__``. + Messages carry the method, path, and status — never the request + headers, so the panel token can never leak through an error. + """ + + def __init__(self, message: str, *, status: int | None = None) -> None: + super().__init__(message) + self.status = status + + @property + def is_transport_error(self) -> bool: + """True when the exchange failed before any response arrived.""" + return self.status is None + + +def is_valid_base_url(value: object) -> bool: + """Return True when ``value`` is a usable API base URL. + + A full ``http://`` or ``https://`` URL with a host — the exact + shape the options flow validates (form ergonomics) and the client + constructor enforces (fail fast, never build requests against a + scheme-less or host-less string). + """ + if not isinstance(value, str): + return False + candidate = value.strip() + if not candidate: + return False + parsed = urlparse(candidate) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def resolve_api_config(entry: Any) -> tuple[str, str]: + """Resolve ``(base_url, panel_token)`` off a config entry. + + Precedence mirrors the options flow's stored-values resolution: + ``entry.options`` first (the live options-flow copy), then + ``entry.data`` (the config-flow-era copy), then the const defaults. + The token is a secret: callers must never log the returned pair. + """ + options = getattr(entry, "options", None) or {} + data = getattr(entry, "data", None) or {} + + def _pick(key: str, default: str) -> str: + if key in options: + return options[key] + if key in data: + return data[key] + return default + + return ( + _pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL), + _pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN), + ) + + +def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient: + """Build the production client for a config entry. + + Reads the configured base URL and panel token off the entry + (:func:`resolve_api_config`) and hands the client Home Assistant's + SHARED aiohttp session (``homeassistant.helpers.aiohttp_client + .async_get_clientsession``) so panel calls ride HA's one connection + pool. The helper import is deferred into this function on purpose: + the test harness is mock-only and the module must import (and the + client must be stub-transport testable) without homeassistant or + aiohttp installed. + """ + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + base_url, panel_token = resolve_api_config(entry) + return NestQuestApiClient( + base_url, panel_token, async_get_clientsession(hass) + ) + + +class NestQuestApiClient: + """Async client for the NestQuest API service's panel plane. + + ONE instance per config entry (the coordinator task wires it into + the refresh cycle). The client is deliberately thin: it adds the + Bearer credential and the base URL, enforces one timeout per + request, and turns every failure into :class:`NestQuestApiError` — + no business logic, no retries, no caching. + """ + + def __init__( + self, + base_url: str, + panel_token: str, + session: aiohttp.ClientSession, + *, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + ) -> None: + """Store the connection settings; the session is injected. + + ``session`` is an aiohttp ``ClientSession`` in production and a + stub transport in tests. ``base_url`` must be a full http(s) + URL and ``panel_token`` must be non-empty — a client built + without either can only fail later at request time with a + less-explicable error, so construction fails fast instead. + """ + if not is_valid_base_url(base_url): + raise ValueError( + f"base_url must be a full http(s) URL, got {base_url!r}" + ) + if not isinstance(panel_token, str) or not panel_token.strip(): + raise ValueError("panel_token must be a non-empty string") + if ( + not isinstance(timeout_seconds, (int, float)) + or isinstance(timeout_seconds, bool) + or timeout_seconds <= 0 + ): + raise ValueError( + f"timeout_seconds must be a positive number, got {timeout_seconds!r}" + ) + self._base_url = base_url.strip().rstrip("/") + self._panel_token = panel_token + self._session = session + self._timeout_seconds = float(timeout_seconds) + + async def get_snapshot(self) -> dict[str, Any]: + """Fetch and parse today's household snapshot. + + Returns the JSON the snapshot route returns (``today_iso``, + ``cycle_day``, and the per-child list — see + ``api/routes_panel.py`` for the documented shape). Raises + :class:`NestQuestApiError` on transport failure, timeout, or a + non-2xx response. + """ + return await self._request_json("GET", SNAPSHOT_PATH) + + async def complete_instance( + self, instance_id: int, actor_child_id: int + ) -> dict[str, Any]: + """Complete one quest instance as the tapped child profile. + + POSTs ``{"actor_child_id": actor_child_id}`` to the complete + route and returns the parsed response body (the route answers + ``{"status": ...}`` both for a first completion and for an + idempotent repeat, so a retried panel tap succeeds). Raises + :class:`NestQuestApiError` on transport failure, timeout, or a + non-2xx response — 401 for a wrong token, 404 for an unknown + instance. + """ + path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id) + return await self._request_json( + "POST", path, json_body={"actor_child_id": actor_child_id} + ) + + async def _request_json( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> Any: + """Run ONE request and return the parsed JSON body. + + The Bearer token rides the Authorization header only. A + non-2xx response raises :class:`NestQuestApiError` with its + status; anything the transport raises (connection, timeout) is + wrapped as a transport-level :class:`NestQuestApiError` with + the original error chained — never swallowed, never retried. + A 2xx body that is not valid JSON is a protocol failure and + raises typed too, carrying the status it arrived with. + """ + url = f"{self._base_url}{path}" + headers = {"Authorization": f"Bearer {self._panel_token}"} + try: + async with asyncio.timeout(self._timeout_seconds): + async with self._session.request( + method, url, headers=headers, json=json_body + ) as response: + status = response.status + if not 200 <= status < 300: + raise NestQuestApiError( + f"NestQuest API {method} {path} failed with " + f"HTTP {status}", + status=status, + ) + body_text = await response.text() + except asyncio.CancelledError: + # Cancellation is never converted: a shutting-down event + # loop must stop, not read a synthesized API error. + raise + except NestQuestApiError: + raise + except Exception as err: + raise NestQuestApiError( + f"NestQuest API {method} {path} transport failure: {err}", + status=None, + ) from err + try: + return json.loads(body_text) + except ValueError as err: + raise NestQuestApiError( + f"NestQuest API {method} {path} returned malformed " + f"JSON: {err}", + status=status, + ) from err \ No newline at end of file diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index fc72aff..dfecc08 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -101,6 +101,32 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled" CONF_CELEBRATION_ENABLED = "celebration_enabled" DEFAULT_AUTOMATION_ENABLED = True +# Panel-plane API client (Feature 18): the integration reaches the +# NestQuest API service (Feature 16) over HTTP instead of the database. +# The base URL points at the API service host serving ``/api/v1/panel/*`` +# (uvicorn's default host/port is the sensible starting point); the panel +# service token is the static Bearer credential the panel plane requires +# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the +# entry like every other setting — options first, then the entry-data +# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never +# echoed into error messages, and never committed. The shipped default +# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no +# configured token is therefore NOT a working client: the options flow +# accepts an empty token as "not configured yet" and only rejects +# malformed (padded/whitespace-only/non-string) values — the actual +# non-emptiness guard is enforced at client construction, where +# NestQuestApiClient fails fast on an empty/whitespace token +# (api_client.py). So a caller building the client from a bare +# entry must either ensure a token is configured first or handle the +# construction ValueError and surface an "API not configured" state — +# an unconfigured entry never yields a client that can only 401. +# This mirrors the API service's fail-closed stance (api/config.py: no +# default token exists). +CONF_API_BASE_URL = "api_base_url" +DEFAULT_API_BASE_URL = "http://127.0.0.1:8000" +CONF_PANEL_TOKEN = "panel_token" +DEFAULT_PANEL_TOKEN = "" + # Quest windows (D-008): a definition may declare several day windows; # each produces its own instance per day and groups the panel's Quest # Log into three columns. The clock ranges are stored here so they stay diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 22e3ee2..26cdcb9 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -12,6 +12,7 @@ from .const import ( CONF_ADMIN_USER_IDS, CONF_AFTERNOON_REMINDER_ENABLED, CONF_AFTERNOON_REMINDER_TIME, + CONF_API_BASE_URL, CONF_CELEBRATION_ENABLED, CONF_DAY_ROLLOVER_TIME, CONF_END_OF_DAY_REPORT_ENABLED, @@ -21,7 +22,9 @@ from .const import ( CONF_MORNING_SUMMARY_TIME, CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, + CONF_PANEL_TOKEN, CONF_UPDATE_INTERVAL, + DEFAULT_API_BASE_URL, DEFAULT_AFTERNOON_REMINDER_TIME, DEFAULT_AUTOMATION_ENABLED, DEFAULT_DAY_ROLLOVER_TIME, @@ -29,12 +32,14 @@ from .const import ( DEFAULT_HORIZON_DAYS, DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_IDLE_TIMEOUT, + DEFAULT_PANEL_TOKEN, DEFAULT_UPDATE_INTERVAL, DOMAIN, MIN_UPDATE_INTERVAL, TIME_PATTERN, ) from .admin_allowlist import list_admin_ids, set_admin_ids +from .api_client import is_valid_base_url MIN_HORIZON_DAYS = 1 MIN_PANEL_IDLE_TIMEOUT = 30 @@ -116,6 +121,25 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: if toggle_value is not None and not isinstance(toggle_value, bool): errors[toggle_key] = "invalid" + # The panel-plane API connection (Feature 18) is OPTIONAL in raw + # submits the same way: a missing key keeps the current value. The + # base URL must be a full http(s) URL with a host — the same check + # the client's constructor enforces, so a malformed URL fails at + # the form, not at the first request. The token may be EMPTY (the + # API connection is simply not configured yet) but a non-empty one + # must be pasted unpadded: a padded or whitespace-only value is a + # clipboard mistake that would fail the token check at request + # time. + api_base_url = user_input.get(CONF_API_BASE_URL) + if api_base_url is not None and not is_valid_base_url(api_base_url): + errors[CONF_API_BASE_URL] = "invalid_url" + panel_token = user_input.get(CONF_PANEL_TOKEN) + if panel_token is not None and ( + not isinstance(panel_token, str) + or panel_token != panel_token.strip() + ): + errors[CONF_PANEL_TOKEN] = "invalid" + return errors @@ -186,6 +210,18 @@ def _build_schema( CONF_CELEBRATION_ENABLED, default=current[CONF_CELEBRATION_ENABLED], ): _strict_bool, + # Panel-plane API connection (Feature 18): the base URL the API + # client talks to and the panel service token it presents as + # the Bearer credential. Schema-validated submits always carry + # both (the defaults pre-fill from the stored values); raw + # callers omitting them keep their stored values (see + # _async_validate). + vol.Required( + CONF_API_BASE_URL, default=current[CONF_API_BASE_URL] + ): str, + vol.Required( + CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN] + ): str, } if admin_choices is not None: # HA's supported multi-select validator: the frontend can @@ -336,6 +372,11 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): data[notification_key] = user_input.get( notification_key, current[notification_key] ) + # The API connection section (Feature 18) stores the base URL + # and panel token the same fall-back-to-current way, so raw + # submits that predate the section keep their stored values. + for api_key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN): + data[api_key] = user_input.get(api_key, current[api_key]) if admin_ids_present: data[CONF_ADMIN_USER_IDS] = admin_ids elif admin_default: @@ -365,6 +406,8 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED), (CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED), (CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED), + (CONF_API_BASE_URL, DEFAULT_API_BASE_URL), + (CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN), ): if key in options: values[key] = options[key] diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index a37a39a..3e6312a 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -18,11 +18,13 @@ "step": { "init": { "title": "NestQuest Settings", - "description": "Adjust how NestQuest plans, rolls over days, and idles its panel.", + "description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.", "data": { "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", + "api_base_url": "NestQuest API base URL (http://host:port)", + "panel_token": "Panel service token", "admin_user_ids": "Admin users (parents)" } } @@ -30,6 +32,7 @@ "errors": { "invalid": "Invalid value.", "invalid_time": "Time must use 24-hour HH:MM format.", + "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", "invalid_admin": "Invalid admin selection." } diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index a37a39a..3e6312a 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -18,11 +18,13 @@ "step": { "init": { "title": "NestQuest Settings", - "description": "Adjust how NestQuest plans, rolls over days, and idles its panel.", + "description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.", "data": { "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", + "api_base_url": "NestQuest API base URL (http://host:port)", + "panel_token": "Panel service token", "admin_user_ids": "Admin users (parents)" } } @@ -30,6 +32,7 @@ "errors": { "invalid": "Invalid value.", "invalid_time": "Time must use 24-hour HH:MM format.", + "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", "invalid_admin": "Invalid admin selection." } diff --git a/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 0000000..4e9d5a9 --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,450 @@ +"""Tests for the panel-plane API client (Feature 18). + +The client is exercised against a STUB transport only — no network, no +aiohttp, no homeassistant: the injected session is a fake that speaks +the same ``request(...)`` surface aiohttp's ``ClientSession`` exposes +and records every call, so the tests assert exactly what the client +puts on the wire (method, URL, Bearer header, JSON body) and how every +failure mode (non-2xx, connection failure, timeout, malformed JSON) +surfaces as the typed :class:`NestQuestApiError`. +""" +from __future__ import annotations + +import asyncio +import json +import sys +import types + +import pytest + +from conftest import make_config_entry + +from custom_components.nestquest import api_client +from custom_components.nestquest.api_client import ( + NestQuestApiClient, + NestQuestApiError, + is_valid_base_url, + resolve_api_config, +) +from custom_components.nestquest.const import ( + CONF_API_BASE_URL, + CONF_PANEL_TOKEN, + DEFAULT_API_BASE_URL, + DEFAULT_PANEL_TOKEN, +) + +BASE_URL = "http://api.test:8000" +TOKEN = "test-panel-token" + +#: A realistic panel snapshot payload (the documented shape of the +#: Feature 16 snapshot route). +SNAPSHOT_PAYLOAD = { + "today_iso": "2026-09-23", + "cycle_day": 2, + "children": [ + { + "child_id": 1, + "child_name": "Ada", + "present": False, + "next_present": "2026-09-24", + "due_today": 1, + "completed_today": 0, + "remaining_today": 1, + "completion_pct": 0, + "instances": [ + { + "id": 7, + "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, + } + ], + } + ], +} + + +class StubResponse: + """A fake aiohttp response: ``status`` plus ``await text()``.""" + + def __init__(self, status: int, body: str) -> None: + self.status = status + self._body = body + + async def text(self) -> str: + return self._body + + +class _OutcomeContext: + """Async context manager yielding one scripted outcome. + + Mirrors aiohttp's ``session.request(...)`` return value: entering + the context performs the request, so a scripted transport error + raises THERE — before any response object exists, exactly where + aiohttp's connector raises on a refused connection. + """ + + def __init__(self, response, error) -> None: + self._response = response + self._error = error + + async def __aenter__(self): + if self._error is not None: + raise self._error + return self._response + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + +class StubTransport: + """A no-network stand-in for aiohttp's ``ClientSession``. + + Records every ``request(...)`` call as a dict (method, url, headers, + json body) and answers from a scripted queue of outcomes — a + :class:`StubResponse` or an exception per call, consumed in order — + so each test drives exactly one exchange. + """ + + def __init__(self, *outcomes) -> None: + self.calls: list[dict] = [] + self._outcomes = list(outcomes) + + def request(self, method: str, url: str, **kwargs): + self.calls.append( + { + "method": method, + "url": url, + "headers": kwargs.get("headers"), + "json": kwargs.get("json"), + } + ) + if not self._outcomes: + raise AssertionError("StubTransport ran out of scripted outcomes") + outcome = self._outcomes.pop(0) + if isinstance(outcome, BaseException): + return _OutcomeContext(None, outcome) + return _OutcomeContext(outcome, None) + + +class HangingTransport: + """A transport whose request never answers (for the timeout test).""" + + def __init__(self) -> None: + self.calls: list[dict] = [] + + def request(self, method: str, url: str, **kwargs): + self.calls.append({"method": method, "url": url}) + return _HangingContext() + + +class _HangingContext: + async def __aenter__(self): + await asyncio.sleep(5) + raise AssertionError("hanging transport was never cancelled") + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + +def make_client(transport, *, timeout_seconds: float = 5.0) -> NestQuestApiClient: + """Build a client over the stub transport with the test credentials.""" + return NestQuestApiClient( + BASE_URL, TOKEN, transport, timeout_seconds=timeout_seconds + ) + + +# --------------------------------------------------------------------------- +# Success paths +# --------------------------------------------------------------------------- + + +async def test_get_snapshot_parses_payload_and_sends_bearer() -> None: + transport = StubTransport( + StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)) + ) + client = make_client(transport) + + snapshot = await client.get_snapshot() + + assert snapshot == SNAPSHOT_PAYLOAD + call = transport.calls[0] + assert call["method"] == "GET" + assert call["url"] == f"{BASE_URL}/api/v1/panel/snapshot" + assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"} + assert call["json"] is None + + +async def test_complete_instance_posts_body_and_headers() -> None: + transport = StubTransport(StubResponse(200, '{"status": "done"}')) + client = make_client(transport) + + result = await client.complete_instance(42, 3) + + assert result == {"status": "done"} + call = transport.calls[0] + assert call["method"] == "POST" + assert call["url"] == f"{BASE_URL}/api/v1/panel/instances/42/complete" + assert call["json"] == {"actor_child_id": 3} + assert call["headers"]["Authorization"] == f"Bearer {TOKEN}" + + +async def test_base_url_trailing_slash_is_normalized() -> None: + """A base URL with a trailing slash must not double the path slash.""" + transport = StubTransport(StubResponse(200, "{}")) + client = NestQuestApiClient( + f"{BASE_URL}/", TOKEN, transport, timeout_seconds=5.0 + ) + await client.get_snapshot() + assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/snapshot" + + +# --------------------------------------------------------------------------- +# HTTP failures: the typed error carries the status +# --------------------------------------------------------------------------- + + +async def test_snapshot_401_raises_typed_error() -> None: + """A wrong/missing token answers 401 and surfaces with that status.""" + transport = StubTransport( + StubResponse(401, '{"detail": "Missing or invalid panel service token"}') + ) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + error = excinfo.value + assert error.status == 401 + assert not error.is_transport_error + assert error.__cause__ is None + # The token is a secret: it never reaches the raised message. + assert TOKEN not in str(error) + + +async def test_complete_unknown_instance_404_raises_typed_error() -> None: + transport = StubTransport( + StubResponse(404, '{"detail": "Quest instance not found"}') + ) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.complete_instance(999999, 1) + + assert excinfo.value.status == 404 + assert not excinfo.value.is_transport_error + + +async def test_snapshot_500_raises_typed_error() -> None: + """Any non-2xx response is typed, whatever the status.""" + transport = StubTransport(StubResponse(500, "{}")) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + assert excinfo.value.status == 500 + + +async def test_malformed_2xx_body_raises_typed_with_status() -> None: + """A 2xx body that is not JSON is a protocol failure, carrying its status.""" + transport = StubTransport(StubResponse(200, "not json")) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + assert excinfo.value.status == 200 + + +# --------------------------------------------------------------------------- +# Transport failures: the typed error carries no status and chains the cause +# --------------------------------------------------------------------------- + + +async def test_connection_failure_raises_transport_error() -> None: + transport = StubTransport(OSError("connection refused")) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + error = excinfo.value + assert error.status is None + assert error.is_transport_error + assert isinstance(error.__cause__, OSError) + + +async def test_connection_failure_on_complete_is_typed() -> None: + transport = StubTransport(OSError("connection refused")) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.complete_instance(7, 1) + + assert excinfo.value.status is None + assert excinfo.value.is_transport_error + + +async def test_request_timeout_raises_transport_error() -> None: + """A hung API call surfaces as a typed transport error, once, with + no retry: the one request is cancelled by the client's timeout.""" + transport = HangingTransport() + client = make_client(transport, timeout_seconds=0.05) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + assert excinfo.value.status is None + assert excinfo.value.is_transport_error + assert len(transport.calls) == 1 + + +# --------------------------------------------------------------------------- +# Construction and config resolution +# --------------------------------------------------------------------------- + + +def test_constructor_requires_full_base_url() -> None: + for bad_url in (None, "", " ", "api.lan:8000", "http://", "ftp://x"): + with pytest.raises(ValueError): + NestQuestApiClient(bad_url, TOKEN, StubTransport()) + + +def test_constructor_requires_non_empty_token() -> None: + for bad_token in (None, "", " "): + with pytest.raises(ValueError): + NestQuestApiClient(BASE_URL, bad_token, StubTransport()) + + +def test_constructor_requires_positive_timeout() -> None: + for bad_timeout in (0, -1, "5", True, None): + with pytest.raises(ValueError): + NestQuestApiClient( + BASE_URL, TOKEN, StubTransport(), timeout_seconds=bad_timeout + ) + + +def test_is_valid_base_url_accepts_full_http_urls() -> None: + assert is_valid_base_url("http://127.0.0.1:8000") + assert is_valid_base_url("https://nestquest.lan") + assert is_valid_base_url(" http://127.0.0.1:8000 ") + assert not is_valid_base_url("api.lan:8000") + assert not is_valid_base_url("http://") + assert not is_valid_base_url("") + assert not is_valid_base_url(None) + assert not is_valid_base_url(42) + + +def test_resolve_api_config_prefers_options_then_data_then_defaults() -> None: + options_only = make_config_entry( + options={CONF_API_BASE_URL: "http://from-options:1"} + ) + assert resolve_api_config(options_only) == ( + "http://from-options:1", + DEFAULT_PANEL_TOKEN, + ) + + data_only = make_config_entry( + data={ + CONF_API_BASE_URL: "http://from-data:2", + CONF_PANEL_TOKEN: "from-data", + } + ) + assert resolve_api_config(data_only) == ("http://from-data:2", "from-data") + + both = make_config_entry( + options={CONF_API_BASE_URL: "http://from-options:3"}, + data={ + CONF_API_BASE_URL: "http://from-data:4", + CONF_PANEL_TOKEN: "from-data", + }, + ) + assert resolve_api_config(both) == ( + "http://from-options:3", + "from-data", + ) + + bare = make_config_entry() + assert resolve_api_config(bare) == (DEFAULT_API_BASE_URL, DEFAULT_PANEL_TOKEN) + + +def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None: + """The production factory resolves the entry config and hands in HA's + shared aiohttp session (stubbed module — no real HA, no network).""" + fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client") + shared_session = object() + fake_module.async_get_clientsession = lambda hass: shared_session + monkeypatch.setitem( + sys.modules, "homeassistant.helpers.aiohttp_client", fake_module + ) + entry = make_config_entry( + options={ + CONF_API_BASE_URL: "http://panel.lan:8443", + CONF_PANEL_TOKEN: "entry-token", + } + ) + + client = api_client.client_from_entry(object(), entry) + + assert client._session is shared_session + assert client._base_url == "http://panel.lan:8443" + assert client._panel_token == "entry-token" + + +def test_client_from_entry_unconfigured_token_raises_at_construction( + monkeypatch, +) -> None: + """An entry with NO configured token (the shipped default) raises + ValueError AT CONSTRUCTION — the client's fail-fast guard — rather + than silently building a client that can only 401 (the documented + contract: the options flow's non-empty validation is the user-facing + guard; the coordinator must ensure a token is configured first or + handle the construction error as "API not configured").""" + fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client") + fake_module.async_get_clientsession = lambda hass: object() + monkeypatch.setitem( + sys.modules, "homeassistant.helpers.aiohttp_client", fake_module + ) + entry = make_config_entry( + options={CONF_API_BASE_URL: DEFAULT_API_BASE_URL}, + ) + assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == "" + + with pytest.raises(ValueError, match="panel_token must be a non-empty"): + api_client.client_from_entry(object(), entry) + + # Same for a whitespace-only stored token: whitespace is not a token. + entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "}) + with pytest.raises(ValueError, match="panel_token must be a non-empty"): + api_client.client_from_entry(object(), entry_ws) + + +async def test_client_logs_nothing() -> None: + """The client emits no log records at all (the token must never + reach a log line), success or failure.""" + import logging + + records: list[logging.LogRecord] = [] + + class _Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Capture() + logging.getLogger().addHandler(handler) + try: + transport = StubTransport(StubResponse(401, "{}")) + client = make_client(transport) + with pytest.raises(NestQuestApiError): + await client.get_snapshot() + finally: + logging.getLogger().removeHandler(handler) + assert records == [] \ No newline at end of file diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 1c8bf88..c2bcdce 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -15,22 +15,26 @@ from custom_components.nestquest import config_flow from custom_components.nestquest import options_flow from custom_components.nestquest.const import ( CONF_ADMIN_USER_IDS, - CONF_DAY_ROLLOVER_TIME, - CONF_HORIZON_DAYS, - CONF_PANEL_IDLE_TIMEOUT, - CONF_UPDATE_INTERVAL, CONF_AFTERNOON_REMINDER_ENABLED, CONF_AFTERNOON_REMINDER_TIME, + CONF_API_BASE_URL, CONF_CELEBRATION_ENABLED, + CONF_DAY_ROLLOVER_TIME, CONF_END_OF_DAY_REPORT_ENABLED, CONF_END_OF_DAY_REPORT_TIME, + CONF_HORIZON_DAYS, CONF_MORNING_SUMMARY_ENABLED, CONF_MORNING_SUMMARY_TIME, CONF_NOTIFY_TARGET, + CONF_PANEL_IDLE_TIMEOUT, + CONF_PANEL_TOKEN, + CONF_UPDATE_INTERVAL, + DEFAULT_API_BASE_URL, DEFAULT_AFTERNOON_REMINDER_TIME, DEFAULT_AUTOMATION_ENABLED, DEFAULT_END_OF_DAY_REPORT_TIME, DEFAULT_MORNING_SUMMARY_TIME, + DEFAULT_PANEL_TOKEN, DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, @@ -49,8 +53,9 @@ VALID_INPUT = { } #: What a schema-validated submit produces from a bare VALID_INPUT on a -#: fresh entry: the Feature 10 refresh interval and the Feature 11 -#: notification section fill from their defaults. +#: fresh entry: the Feature 10 refresh interval, the Feature 11 +#: notification section, and the Feature 18 API connection fill from +#: their defaults. FULL_INPUT = { **VALID_INPUT, CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, @@ -62,6 +67,8 @@ FULL_INPUT = { CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED, CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED, CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED, + CONF_API_BASE_URL: DEFAULT_API_BASE_URL, + CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN, } @@ -259,6 +266,8 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_AFTERNOON_REMINDER_ENABLED: True, CONF_END_OF_DAY_REPORT_ENABLED: True, CONF_CELEBRATION_ENABLED: True, + CONF_API_BASE_URL: DEFAULT_API_BASE_URL, + CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN, CONF_ADMIN_USER_IDS: [], } @@ -272,6 +281,9 @@ def test_options_flow_schema_coerces_field_types() -> None: assert validators[CONF_HORIZON_DAYS] is int assert validators[CONF_DAY_ROLLOVER_TIME] is str assert validators[CONF_PANEL_IDLE_TIMEOUT] is int + # The Feature 18 API connection fields are free-text strings. + assert validators[CONF_API_BASE_URL] is str + assert validators[CONF_PANEL_TOKEN] is str @pytest.mark.parametrize("bad_horizon", [0, -5]) @@ -326,8 +338,10 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} -def test_options_flow_creates_entry_with_four_keys() -> None: - """A valid submit creates an entry with exactly the four validated keys.""" +def test_options_flow_creates_entry_with_validated_keys() -> None: + """A valid submit creates an entry with exactly the validated keys + (the legacy settings plus every later section's keys, filled from + defaults where the raw submit omitted them).""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -799,3 +813,91 @@ def test_options_flow_no_hard_coded_personal_target_in_source() -> None: assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == "" form = _run(_make_flow(_make_entry()).async_step_init(None)) assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == "" + + +# --------------------------------------------------------------------------- +# Panel-plane API connection section (Feature 18) +# --------------------------------------------------------------------------- + + +def test_options_flow_api_section_defaults_saved() -> None: + """A bare submit saves the API connection defaults: the const base + URL and an EMPTY panel token (no token is ever invented).""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_API_BASE_URL] == DEFAULT_API_BASE_URL + assert result["data"][CONF_PANEL_TOKEN] == "" + + +def test_options_flow_api_base_url_must_be_full_http_url() -> None: + """Scheme-less, host-less, empty, and non-string base URLs reject + with a field error — the same rule the client's constructor + enforces, so a malformed URL fails at the form, not at the first + request.""" + flow = _make_flow(_make_entry()) + for bad_url in ("api.lan:8000", "http://", "", " ", 42): + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_API_BASE_URL: bad_url} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_API_BASE_URL: "invalid_url"} + + +def test_options_flow_panel_token_validation() -> None: + """The EMPTY token is valid (the API connection is simply not + configured yet); padded and non-string values reject — a padded + paste would fail the token check at request time instead.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_PANEL_TOKEN: ""}) + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_PANEL_TOKEN] == "" + + for bad_token in (" secret ", " ", 42): + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_PANEL_TOKEN: bad_token} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_PANEL_TOKEN: "invalid"} + + +def test_options_flow_api_section_stored_values_round_trip() -> None: + """Stored API settings are the form's defaults and a raw legacy + submit without them keeps the stored values.""" + entry = _make_entry( + options={ + **VALID_INPUT, + CONF_API_BASE_URL: "http://panel.lan:8443", + CONF_PANEL_TOKEN: "stored-token", + } + ) + flow = _make_flow(entry) + form = _run(flow.async_step_init(None)) + schema_defaults = form["data_schema"]({}) + assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443" + assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token" + + # A raw submit carrying only the legacy keys keeps the stored + # API connection settings. + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_API_BASE_URL] == "http://panel.lan:8443" + assert result["data"][CONF_PANEL_TOKEN] == "stored-token" + + +def test_options_flow_strings_cover_api_section( + strings: dict, translations_en: dict +) -> None: + """Both string files name the API fields and the URL error.""" + data = strings["options"]["step"]["init"]["data"] + for key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN): + assert data.get(key), f"Missing field name for '{key}'" + assert strings["options"]["errors"]["invalid_url"] + assert translations_en["options"] == strings["options"] -- 2.55.0 From fafabeabbe5e55c9323b2230dc085d2e188b7087 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 06:33:44 -0400 Subject: [PATCH 2/7] Task 6e70d166: Coordinator polls the snapshot and builds the sensors (#167) --- custom_components/nestquest/__init__.py | 48 +- custom_components/nestquest/coordinator.py | 255 +++++-- tests/conftest.py | 170 ++++- tests/test_coordinator.py | 737 ++++++++++++++------- tests/test_core_snapshot.py | 47 +- tests/test_entity_matrix.py | 15 +- tests/test_sensors.py | 35 +- 7 files changed, 987 insertions(+), 320 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 6b0eac4..bbcb3ad 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -26,7 +26,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 +490,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 +512,30 @@ 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. 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 ConfigEntryNotReady: + 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 @@ -548,10 +569,19 @@ 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 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..1d634e1 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,56 @@ 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, 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 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 homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, + UpdateFailed, +) +from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config from .const import ( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, @@ -48,13 +64,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 +78,120 @@ __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; + :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__ = () + + 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 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 complete/uncomplete calls must not interleave their snapshot @@ -82,8 +206,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 +223,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 +233,67 @@ 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 + """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 UpdateFailed(str(err)) from err + 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``. + """ + 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..d7e836e 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"), @@ -365,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. @@ -373,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): @@ -384,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): @@ -403,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.""" @@ -424,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 @@ -700,6 +742,122 @@ 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 + + _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..25dc0a0 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -1,263 +1,512 @@ -"""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 +from homeassistant.helpers.update_coordinator import UpdateFailed 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 -) -> None: - """A child absent today carries the ISO date of their next present - day; an always-absent pattern carries None instead.""" - 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(), "" - ) +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() - 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 + 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_reflects_completion_and_uncompletion( - 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 _seed_day(database) - ada = ( +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) 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, + ) + # 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: 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() - ).children[0] - instance_id = ada.instances[0].instance_id + assert isinstance(excinfo.value.__cause__, NestQuestApiError) - 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" + # 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_coordinator_zero_quests_reports_full_percentage( +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( + hass, make_entry, monkeypatch +) -> None: + """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 - 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 == () + 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) + # 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(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_cycle_day_from_first_scheduled_child( +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 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 +519,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 +544,23 @@ 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( +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 against the database the except path closes.""" + 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 @@ -330,17 +575,37 @@ 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: + """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..fa29adb 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,10 +260,13 @@ 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 attributes["admin_instances"] == [], ( + "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 -- 2.55.0 From 84d179336b9b1e822a5c24fe4484f361939fe4bb Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 07:04:39 -0400 Subject: [PATCH 3/7] Task b1ed9c45: SSE subscription re-fires the four HA bus events (#168) --- custom_components/nestquest/__init__.py | 44 +- custom_components/nestquest/api_client.py | 131 +++++ custom_components/nestquest/sse.py | 172 ++++++ tests/test_sse_subscription.py | 619 ++++++++++++++++++++++ 4 files changed, 965 insertions(+), 1 deletion(-) create mode 100644 custom_components/nestquest/sse.py create mode 100644 tests/test_sse_subscription.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index bbcb3ad..96cbb41 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -31,6 +31,7 @@ from .db import NestQuestDatabase, make_database from .materialize import materialize as _materialize_run from .migrations import apply_migrations from .services import async_deregister_services, async_register_services +from .sse import NestQuestEventStream from .store import async_get_db_path from .admin_allowlist import seed_setup_admin from .sweep import run_missed_sweep @@ -242,6 +243,7 @@ class NestQuestRuntimeData: remove_update_listener: Callable[[], Any] remove_time_change_listener: Callable[[], Any] coordinator: Any = None + event_stream: NestQuestEventStream | None = None async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: @@ -439,6 +441,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_update_listener: Callable[[], Any] | None = None remove_time_change_listener: Callable[[], Any] | None = None coordinator: Any = None + event_stream: NestQuestEventStream | None = None try: # Seed the admin allowlist on first setup so the owner is never # locked out: an empty database takes the persisted admin copy @@ -496,14 +499,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # (unavailable entities, never a setup crash). Created AFTER # services so a failure below unwinds them through the except # path's listener handling. + # ONE API client for the entry, shared by the coordinator's + # snapshot polling and the SSE subscription below (the client + # is stateless — one session, one connection pool). + api_client = coordinator_client_from_entry(hass, entry) + # The shared Feature 10 coordinator: one refresh cycle every + # entity reads from (CONF_UPDATE_INTERVAL seconds, default + # 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, - api_client=coordinator_client_from_entry(hass, entry), + api_client=api_client, update_interval_seconds=entry.options.get( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL ), ) + # The panel event subscription (the SSE task): a background + # task consumes the API service's transition-event stream and + # re-fires the four documented HA bus events. It reuses the + # coordinator's client, so it only starts when that client + # actually offers a stream — an unconfigured-token stub (or a + # test snapshot client without ``stream_events``) leaves it + # unstarted, and the typed-error path already covers + # availability for those entries. + if callable(getattr(api_client, "stream_events", None)): + event_stream = NestQuestEventStream(hass, api_client) + event_stream.start() runtime_data = NestQuestRuntimeData( entry_id=entry.entry_id, settings=settings, @@ -511,6 +537,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_update_listener=remove_update_listener, remove_time_change_listener=remove_time_change_listener, coordinator=coordinator, + event_stream=event_stream, ) # The runtime record is registered BEFORE the first refresh: # the refresh path (and anything it calls) resolves the entry's @@ -543,6 +570,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # unwind EVERY remover we acquired before the failure, keeping the # original error as the one raised. Removal is best-effort — a # failing remover must not mask the setup error that triggered it. + # The SSE subscription (started just before the runtime record) + # is stopped first: like the coordinator it must never outlive + # the database being closed below. + if event_stream is not None: + try: + await event_stream.stop() + except BaseException: + pass for remove_listener in ( remove_time_change_listener, remove_update_listener, @@ -611,6 +646,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False unload_error: BaseException | None = None if runtime_data is not None: + # The SSE subscription is stopped FIRST: its task must be gone + # (no frame can fire) before anything else is torn down. + event_stream = getattr(runtime_data, "event_stream", None) + if event_stream is not None: + stop_stream = getattr(event_stream, "stop", None) + if stop_stream is not None: + await stop_stream() remove_update_listener = getattr(runtime_data, "remove_update_listener", None) remove_time_change_listener = getattr( runtime_data, "remove_time_change_listener", None diff --git a/custom_components/nestquest/api_client.py b/custom_components/nestquest/api_client.py index 1a7ed05..7dbf157 100644 --- a/custom_components/nestquest/api_client.py +++ b/custom_components/nestquest/api_client.py @@ -30,11 +30,22 @@ swallowed. There are NO retries and the client logs nothing: refresh cadence is the coordinator's concern (the next task), and the panel token is a secret that must never reach a log line or an error message — the error texts name the method, path, and status only. + +The panel plane's SSE event stream (``GET /api/v1/panel/events``) is +exposed by :meth:`NestQuestApiClient.stream_events` — an async +generator yielding ``(event_type, payload)`` per Server-Sent Events +frame (``event: `` / ``data: `` lines separated by a blank +line). Opening the stream gets the same one-timeout, typed-error +contract as every other call; reading it has NO body-wide timeout (a +healthy stream is long-lived — the subscription manager owns +reconnection and backoff). """ from __future__ import annotations import asyncio +import inspect import json +from collections.abc import AsyncIterator from typing import TYPE_CHECKING, Any from urllib.parse import urlparse @@ -51,6 +62,7 @@ if TYPE_CHECKING: __all__ = [ "COMPLETE_INSTANCE_PATH", "DEFAULT_TIMEOUT_SECONDS", + "EVENTS_PATH", "NestQuestApiError", "NestQuestApiClient", "SNAPSHOT_PATH", @@ -62,6 +74,10 @@ __all__ = [ #: The panel plane's snapshot route (Feature 16). SNAPSHOT_PATH = "/api/v1/panel/snapshot" +#: The panel plane's SSE event-stream route (Feature 16): each +#: transition arrives as an ``event: `` / ``data: `` frame. +EVENTS_PATH = "/api/v1/panel/events" + #: The panel plane's completion route template (Feature 16); the #: ``{instance_id}`` placeholder is filled per call. COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete" @@ -230,6 +246,121 @@ class NestQuestApiClient: "POST", path, json_body={"actor_child_id": actor_child_id} ) + async def stream_events( + self, + ) -> AsyncIterator[tuple[str, dict[str, Any]]]: + """Yield ``(event_type, payload)`` for every panel SSE frame. + + Opens ``GET /api/v1/panel/events`` with the Bearer credential + and parses the Server-Sent Events framing the API service + emits: ``event: `` and ``data: `` lines closed by a + blank line (one frame per transition event; a frame's ``data`` + may span several ``data:`` lines, joined with newlines — the + standard SSE rule). Line splits across chunk boundaries are + buffered, so frames are parsed correctly whatever chunking the + transport produces. + + Opening the stream carries the SAME one-timeout, typed-error + contract as every other call: a non-2xx response raises + :class:`NestQuestApiError` with its status and a transport + failure (connection refused, DNS, the open timing out) raises + one with ``status=None`` and the original error chained. The + reading loop itself has NO timeout — a healthy stream is + long-lived — but any failure raised from the transport + mid-stream raises typed as well, so the consumer can reconnect. + A frame whose ``data`` is not valid JSON is a protocol failure + and raises typed, carrying the response status it arrived + with. Cancellation is never converted: closing the generator + (``aclose``, i.e. the consumer's task being cancelled) stops + cleanly and closes the underlying response in every path. + """ + url = f"{self._base_url}{EVENTS_PATH}" + headers = {"Authorization": f"Bearer {self._panel_token}"} + # Opening the response gets ONE request timeout, exactly like + # ``_request_json``; the read loop below runs WITHOUT one — a + # body-wide timeout would kill a healthy long-lived stream. + # The response context manager is entered manually (not via + # ``async with``) because the block must span the yields below; + # the ``finally`` closes it on EVERY exit path. + try: + async with asyncio.timeout(self._timeout_seconds): + stream_cm = self._session.request( + "GET", url, headers=headers, json=None + ) + response = await stream_cm.__aenter__() + except asyncio.CancelledError: + # Cancellation is never converted (see ``_request_json``). + raise + except NestQuestApiError: + raise + except Exception as err: + raise NestQuestApiError( + f"NestQuest API GET {EVENTS_PATH} transport failure: {err}", + status=None, + ) from err + + status = response.status + if not 200 <= status < 300: + await self._close_stream(stream_cm) + raise NestQuestApiError( + f"NestQuest API GET {EVENTS_PATH} failed with HTTP {status}", + status=status, + ) + + try: + event_type: str | None = None + data_lines: list[str] = [] + buffer = "" + async for chunk in response: + if isinstance(chunk, (bytes, bytearray)): + chunk = chunk.decode("utf-8") + buffer += chunk + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line.startswith("event:"): + event_type = line[len("event:"):].strip() + elif line.startswith("data:"): + data_lines.append(line[len("data:"):].strip()) + elif not line and event_type is not None: + # A blank line ends the frame. A trailing + # incomplete frame (stream ends without its + # closing blank line) is discarded, per the + # SSE spec's treatment of incomplete events. + try: + payload = json.loads("\n".join(data_lines)) + except ValueError as err: + raise NestQuestApiError( + f"NestQuest API GET {EVENTS_PATH} " + f"delivered a malformed SSE frame: {err}", + status=status, + ) from err + yield event_type, payload + event_type = None + data_lines = [] + except asyncio.CancelledError: + raise + except NestQuestApiError: + raise + except Exception as err: + raise NestQuestApiError( + f"NestQuest API GET {EVENTS_PATH} transport failure: {err}", + status=None, + ) from err + finally: + await self._close_stream(stream_cm) + + @staticmethod + async def _close_stream(stream_cm: Any) -> None: + """Close an opened streaming response context manager. + + Works for aiohttp's response context manager (whose + ``__aexit__`` is a coroutine) and for test stubs that return + either a coroutine or a plain value. + """ + result = stream_cm.__aexit__(None, None, None) + if inspect.isawaitable(result): + await result + async def _request_json( self, method: str, diff --git a/custom_components/nestquest/sse.py b/custom_components/nestquest/sse.py new file mode 100644 index 0000000..8d1ad87 --- /dev/null +++ b/custom_components/nestquest/sse.py @@ -0,0 +1,172 @@ +"""HA-bus subscription over the API service's panel SSE stream. + +The NestQuest API service (Feature 16) publishes every quest +transition on ``GET /api/v1/panel/events`` as Server-Sent Events +frames. This module is the integration-side subscriber: ONE +background task consumes :meth:`~.api_client.NestQuestApiClient +.stream_events` and re-fires each frame on ``hass.bus`` with the SAME +event type and payload fields the local transition path +(:mod:`~.events`) emits — ``nestquest_quest_completed``, +``nestquest_quest_uncompleted``, ``nestquest_quest_missed`` and +``nestquest_child_day_complete`` — so the four blueprints fire +identically whether a completion happens through the integration's own +service or through the API service's panel route. + +Failure handling: a failed stream (API down, network blip, non-2xx, a +dropped connection) is retried FOREVER with a bounded exponential +backoff — :data:`RECONNECT_MIN_SECONDS` after the first failure, +doubling up to :data:`RECONNECT_MAX_SECONDS` — so a long API outage +neither pins the event loop nor spams the log, and a frame received on +a healthy stream resets the backoff. Nothing escapes the task: every +failure is logged with its reason and the loop reconnects; a +:class:`asyncio.CancelledError` (from :meth:`stop` or integration +unload) is the only thing that ends it, and it stops cleanly — no +frame fires after :meth:`stop` returns and the response is closed. +""" +from __future__ import annotations + +import asyncio +from typing import Any + +from .const import LOGGER + +__all__ = [ + "NestQuestEventStream", + "RECONNECT_MAX_SECONDS", + "RECONNECT_MIN_SECONDS", +] + +#: Delay before the FIRST reconnect attempt after a stream failure. +#: One failed frame must not turn into a hot reconnect loop. +RECONNECT_MIN_SECONDS = 5.0 + +#: Ceiling for the exponential reconnect backoff: a long API outage +#: retries at a calm fixed cadence instead of growing without bound. +RECONNECT_MAX_SECONDS = 60.0 + + +class NestQuestEventStream: + """Consume the panel SSE stream in the background, fire the HA bus. + + ONE instance per config entry, built in integration setup with the + SAME API client the coordinator polls (the client is stateless — + one shared session, one connection pool). ``start()`` schedules + the background task; ``stop()`` cancels and awaits it, so unload + never leaks the task and no frame fires after teardown. The task + itself is failure-proof: any exception from the stream (typed + :class:`~.api_client.NestQuestApiError` or anything else) is + logged and retried after the current backoff, never allowed to + escape the task. + """ + + def __init__( + self, + hass: Any, + client: Any, + *, + reconnect_min_seconds: float = RECONNECT_MIN_SECONDS, + reconnect_max_seconds: float = RECONNECT_MAX_SECONDS, + ) -> None: + """Store the hass bus target, the API client, and the backoff. + + The client is anything exposing ``stream_events()`` (the real + :class:`~.api_client.NestQuestApiClient` in production, a stub + in tests). The backoff bounds must be positive and the minimum + must not exceed the maximum — a misconfiguration should fail + loudly at construction, not quietly reconnect at delay 0. + """ + if ( + not isinstance(reconnect_min_seconds, (int, float)) + or isinstance(reconnect_min_seconds, bool) + or reconnect_min_seconds <= 0 + or reconnect_min_seconds > reconnect_max_seconds + ): + raise ValueError( + "reconnect_min_seconds must be a positive number no " + f"greater than reconnect_max_seconds, got " + f"{reconnect_min_seconds!r}" + ) + if ( + not isinstance(reconnect_max_seconds, (int, float)) + or isinstance(reconnect_max_seconds, bool) + or reconnect_max_seconds <= 0 + ): + raise ValueError( + f"reconnect_max_seconds must be a positive number, got " + f"{reconnect_max_seconds!r}" + ) + self._hass = hass + self._client = client + self._min_backoff = float(reconnect_min_seconds) + self._max_backoff = float(reconnect_max_seconds) + self._backoff = float(reconnect_min_seconds) + self._task: asyncio.Task | None = None + + @property + def task(self) -> asyncio.Task | None: + """The background task, or ``None`` when never started/stopped.""" + return self._task + + def start(self) -> None: + """Schedule the subscription task (idempotent while it runs).""" + if self._task is not None and not self._task.done(): + return + self._backoff = self._min_backoff + self._task = asyncio.create_task( + self._run(), name="nestquest-sse-subscription" + ) + + async def stop(self) -> None: + """Cancel and await the subscription task. + + Returns only after the task has actually finished, so unload + can rely on: no further frame is fired after ``stop()``, and + the underlying stream response has been closed. Safe to call + when the task was never started or already stopped. + """ + task, self._task = self._task, None + if task is None: + return + task.cancel() + try: + await task + except asyncio.CancelledError: + # The task's own cancellation surfaces here — expected and + # swallowed; a cancelling the CALLER would re-raise out of + # the surrounding await on its own. + pass + + async def _run(self) -> None: + """Consume stream frames forever, reconnecting on failure.""" + while True: + try: + async for event_type, payload in self._client.stream_events(): + # A frame arrived on a healthy stream: the next + # failure reconnects quickly again. + self._backoff = self._min_backoff + try: + self._hass.bus.async_fire(event_type, dict(payload)) + except Exception as err: + # Firing one frame must never tear down a + # healthy connection: log and keep consuming. + LOGGER.error( + "NestQuest panel event %s could not be fired " + "on the Home Assistant event bus: %s", + event_type, + err, + ) + except asyncio.CancelledError: + # Unload / shutdown: stop cleanly, never converted. + raise + except Exception as err: + delay = self._backoff + self._backoff = min( + self._backoff * 2, self._max_backoff + ) + LOGGER.warning( + "NestQuest panel event stream failed (%s); " + "reconnecting in %.1f s", + err, + delay, + ) + await asyncio.sleep(delay) diff --git a/tests/test_sse_subscription.py b/tests/test_sse_subscription.py new file mode 100644 index 0000000..0a5c26d --- /dev/null +++ b/tests/test_sse_subscription.py @@ -0,0 +1,619 @@ +"""Tests for the panel SSE subscription (HA-bus re-firing task). + +Three layers are exercised, all STUB-driven — no network, no aiohttp, +no real Home Assistant: + +- The API client's ``stream_events`` (transport stub): what goes on + the wire (GET, the events URL, the Bearer header), SSE frame + parsing (``event:``/``data:`` lines, blank-line frame ends, chunk + boundaries, multi-line data), and every failure mode (non-2xx, + connection failure, mid-stream drop, malformed frame) surfacing as + the typed :class:`NestQuestApiError`; cancellation closes the + stream cleanly. +- The :class:`~custom_components.nestquest.sse.NestQuestEventStream` + subscription manager (duck-typed stream client): the four documented + transition events (design/ENTITIES-AND-SERVICES.md §3, exactly as + ``tests/test_api_sse.py`` observes them on the API side) fire on + ``hass.bus`` with the documented payload fields; ``stop()`` cancels + the task so no frame fires afterwards; a stream failure reconnects + with a bounded, reset-on-success backoff and never crashes the + task. +- The integration wiring: setup starts the stream alongside the + runtime record, unload tears it down (no firing afterwards, task + cancelled), and an entry whose client has no stream surface (the + unconfigured-token stub, the test snapshot client) starts none. +""" +from __future__ import annotations + +import asyncio +import json + +import pytest + +from conftest import set_coordinator_client, wire_entry_to_registry + +import custom_components.nestquest as nq_integration +from custom_components.nestquest import ( + async_setup_entry, + async_unload_entry, +) +from custom_components.nestquest import sse as sse_module +from custom_components.nestquest.api_client import ( + NestQuestApiClient, + NestQuestApiError, +) +from custom_components.nestquest.sse import NestQuestEventStream + +BASE_URL = "http://api.test:8000" +TOKEN = "test-panel-token" + +# Captured BEFORE any test patches asyncio.sleep (the reconnect test +# swaps in a recording fake; this reference keeps helpers sleeping for +# real regardless). +_REAL_SLEEP = asyncio.sleep + +#: The four documented transition events with their documented payload +#: fields — the exact shapes the API side streams (test_api_sse.py). +DOCUMENTED_FRAMES = ( + ( + "nestquest_quest_completed", + { + "child_id": 1, + "child_name": "Ada", + "instance_id": 7, + "quest_title": "Pack bag", + "window": "morning", + "due_date": "2026-09-23", + "due_time": "10:00", + "occurred_at": "2026-09-23T12:00:00+00:00", + "was_on_time": False, + }, + ), + ( + "nestquest_quest_uncompleted", + { + "child_id": 1, + "child_name": "Ada", + "instance_id": 7, + "quest_title": "Pack bag", + "window": "morning", + "due_date": "2026-09-23", + "due_time": "10:00", + "occurred_at": "2026-09-23T12:00:00+00:00", + }, + ), + ( + "nestquest_quest_missed", + { + "child_id": 1, + "child_name": "Ada", + "instance_id": 9, + "quest_title": "Stale chore", + "window": "morning", + "due_date": "2026-09-23", + "due_time": "09:00", + "occurred_at": "2026-09-23T23:30:00+00:00", + }, + ), + ( + "nestquest_child_day_complete", + { + "child_id": 1, + "child_name": "Ada", + "quests_due": 1, + "quests_completed": 1, + "occurred_at": "2026-09-23T12:00:00+00:00", + }, + ), +) + +#: The documented payload field sets per event type. +DOCUMENTED_FIELDS = { + "nestquest_quest_completed": { + "child_id", + "child_name", + "instance_id", + "quest_title", + "window", + "due_date", + "due_time", + "occurred_at", + "was_on_time", + }, + "nestquest_quest_uncompleted": { + "child_id", + "child_name", + "instance_id", + "quest_title", + "window", + "due_date", + "due_time", + "occurred_at", + }, + "nestquest_quest_missed": { + "child_id", + "child_name", + "instance_id", + "quest_title", + "window", + "due_date", + "due_time", + "occurred_at", + }, + "nestquest_child_day_complete": { + "child_id", + "child_name", + "quests_due", + "quests_completed", + "occurred_at", + }, +} + +#: A minimal panel snapshot payload for the full-setup wiring tests +#: (the shape ``snapshot_from_api_payload`` consumes; no children). +MINIMAL_SNAPSHOT = {"today_iso": "2026-09-23", "cycle_day": 1, "children": []} + + +# --------------------------------------------------------------------------- +# Stub transports and stream clients (no network anywhere) +# --------------------------------------------------------------------------- + + +class _StreamingResponse: + """A fake aiohttp streaming response: ``status`` + chunk iteration. + + ``chunks`` are the body pieces the stream yields in order; a chunk + may be an exception, raised when the iteration reaches it (a + mid-stream transport drop). ``hold_open`` keeps the stream open + forever after the chunks run out, for cancellation tests. + """ + + def __init__(self, status: int, chunks, *, hold_open: bool = False) -> None: + self.status = status + self._chunks = list(chunks) + self._hold_open = hold_open + self.closed = False + + def __aiter__(self): + return self._iterate() + + async def _iterate(self): + for chunk in self._chunks: + if isinstance(chunk, BaseException): + raise chunk + yield chunk + if self._hold_open: + await asyncio.Event().wait() # cancelled, never ends + + +class _RequestContext: + """Async context manager mirroring ``session.request(...)``'s return. + + Entering performs the request (a scripted transport error raises + THERE, where aiohttp's connector raises on a refused connection); + exiting marks the response closed, so tests can assert the stream + was released. + """ + + def __init__(self, response, error) -> None: + self._response = response + self._error = error + + async def __aenter__(self): + if self._error is not None: + raise self._error + return self._response + + async def __aexit__(self, exc_type, exc, tb) -> bool: + if self._response is not None: + self._response.closed = True + return False + + +class _StreamTransport: + """A no-network stand-in for aiohttp's ``ClientSession``. + + Records every ``request(...)`` call and answers from a scripted + queue of outcomes (a :class:`_StreamingResponse` or an exception + per attempt, consumed in order). + """ + + def __init__(self, *outcomes) -> None: + self.calls: list[dict] = [] + self._outcomes = list(outcomes) + + def request(self, method: str, url: str, **kwargs): + self.calls.append( + { + "method": method, + "url": url, + "headers": kwargs.get("headers"), + "json": kwargs.get("json"), + } + ) + outcome = self._outcomes.pop(0) + if isinstance(outcome, BaseException): + return _RequestContext(None, outcome) + return _RequestContext(outcome, None) + + +def make_stream_client(transport) -> NestQuestApiClient: + """Build a client over the streaming stub with the test credentials.""" + return NestQuestApiClient(BASE_URL, TOKEN, transport) + + +def _frame_text(event_type: str, payload: dict) -> str: + """One documented SSE frame: event line, data line, blank line.""" + return f"event: {event_type}\ndata: {json.dumps(payload)}\n\n" + + +class _ScriptedStreamClient: + """Duck-typed API client: one scripted stream per connection. + + ``script`` holds generator FACTORIES — ``stream_events()`` pops the + next one and returns its async generator, so a test scripts exactly + what each connection attempt yields (or how it fails). + """ + + def __init__(self, script) -> None: + self._script = list(script) + self.calls = 0 + + def stream_events(self): + self.calls += 1 + return self._script.pop(0)() + + +def _frames(*frames): + """A live stream factory: yields ``frames``, then stays open.""" + + def _factory(): + async def _gen(): + for frame in frames: + yield frame + await asyncio.Event().wait() # cancelled, never ends + + return _gen() + + return _factory + + +def _fail(error: BaseException): + """A stream factory that fails before yielding anything.""" + + def _factory(): + async def _gen(): + raise error + yield # noqa: unreachable — makes _gen an async generator + + return _gen() + + return _factory + + +def _drop_after(error: BaseException, *frames): + """A stream factory that yields ``frames`` then drops mid-stream.""" + + def _factory(): + async def _gen(): + for frame in frames: + yield frame + raise error + + return _gen() + + return _factory + + +async def _wait_until(predicate, *, timeout: float = 2.0) -> None: + """Poll ``predicate`` until true or the deadline passes.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + while not predicate(): + if loop.time() > deadline: + raise AssertionError("condition not met within the deadline") + await _REAL_SLEEP(0.01) + + +def make_manager( + hass, client, *, min_backoff: float = 0.01, max_backoff: float = 0.05 +) -> NestQuestEventStream: + """A manager with fast (test-sized) backoff bounds.""" + return NestQuestEventStream( + hass, + client, + reconnect_min_seconds=min_backoff, + reconnect_max_seconds=max_backoff, + ) + + +# --------------------------------------------------------------------------- +# The client's stream_events: parsing and typed failures +# --------------------------------------------------------------------------- + + +async def test_stream_events_yields_documented_frames_and_sends_bearer() -> None: + """All four documented frames parse, with the Bearer GET on the wire.""" + body = "".join( + _frame_text(event_type, payload) + for event_type, payload in DOCUMENTED_FRAMES + ) + transport = _StreamTransport(_StreamingResponse(200, [body])) + client = make_stream_client(transport) + + frames = [frame async for frame in client.stream_events()] + + assert frames == list(DOCUMENTED_FRAMES) + call = transport.calls[0] + assert call["method"] == "GET" + assert call["url"] == f"{BASE_URL}/api/v1/panel/events" + assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"} + assert call["json"] is 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.""" + first_type, first_payload = DOCUMENTED_FRAMES[0] + # The first frame is cut MID-LINE across two chunks; the second + # frame spreads its JSON over two ``data:`` lines. + chunks = [ + f"event: {first_type}\nda", + f"ta: {json.dumps(first_payload)}\n\nevent: " + f"{DOCUMENTED_FRAMES[1][0]}\n", + f'data: {{"extra":\ndata: true}}\n\n', + ] + transport = _StreamTransport(_StreamingResponse(200, chunks)) + client = make_stream_client(transport) + + frames = [frame async for frame in client.stream_events()] + + assert len(frames) == 2 + assert frames[0] == DOCUMENTED_FRAMES[0] + # The split-out frame's data spread over two ``data:`` lines joins + # with a newline (the SSE standard) and parses as one JSON body. + assert frames[1] == (DOCUMENTED_FRAMES[1][0], {"extra": True}) + + +async def test_stream_events_non_2xx_raises_typed_and_closes() -> None: + """A wrong token (401) is typed with its status; no stream is read.""" + response = _StreamingResponse(401, ["event: x\ndata: {}\n\n"]) + transport = _StreamTransport(response) + client = make_stream_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + async for _ in client.stream_events(): + pass + + assert excinfo.value.status == 401 + assert not excinfo.value.is_transport_error + assert TOKEN not in str(excinfo.value) + assert response.closed + + +async def test_stream_events_connection_failure_raises_typed_transport_error() -> None: + """A failure before any response is a typed transport error.""" + transport = _StreamTransport(OSError("connection refused")) + client = make_stream_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + async for _ in client.stream_events(): + pass + + assert excinfo.value.status is None + assert excinfo.value.is_transport_error + assert isinstance(excinfo.value.__cause__, OSError) + + +async def test_stream_events_midstream_drop_raises_typed() -> None: + """A transport error mid-stream is typed, so the consumer reconnects.""" + response = _StreamingResponse( + 200, + [_frame_text(DOCUMENTED_FRAMES[0][0], DOCUMENTED_FRAMES[0][1]), OSError("reset")], + ) + transport = _StreamTransport(response) + client = make_stream_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + async for _ in client.stream_events(): + pass + + assert excinfo.value.status is None + assert excinfo.value.is_transport_error + assert isinstance(excinfo.value.__cause__, OSError) + assert response.closed + + +async def test_stream_events_stops_cleanly_on_cancellation() -> None: + """Closing the generator (the consumer task's cancellation) stops + cleanly: no error, the underlying response is closed, and nothing + further is yielded.""" + response = _StreamingResponse( + 200, + [_frame_text(DOCUMENTED_FRAMES[0][0], DOCUMENTED_FRAMES[0][1])], + hold_open=True, + ) + transport = _StreamTransport(response) + client = make_stream_client(transport) + + generator = client.stream_events() + first = await asyncio.wait_for(generator.__anext__(), timeout=2.0) + assert first == DOCUMENTED_FRAMES[0] + + await asyncio.wait_for(generator.aclose(), timeout=2.0) + assert response.closed + + +async def test_stream_events_malformed_frame_raises_typed() -> None: + """A frame whose data is not JSON is a typed protocol failure.""" + response = _StreamingResponse( + 200, ["event: nestquest_quest_completed\ndata: not-json\n\n"] + ) + transport = _StreamTransport(response) + client = make_stream_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + async for _ in client.stream_events(): + pass + + assert excinfo.value.status == 200 + assert response.closed + + +# --------------------------------------------------------------------------- +# The subscription manager: firing, teardown, reconnect +# --------------------------------------------------------------------------- + + +async def test_manager_fires_documented_frames_on_ha_bus(hass) -> None: + """Every documented transition fires on hass.bus with its payload.""" + client = _ScriptedStreamClient([_frames(*DOCUMENTED_FRAMES)]) + manager = make_manager(hass, client) + manager.start() + try: + await _wait_until(lambda: len(hass.bus.events) >= 4) + assert hass.bus.events == [tuple(f) for f in DOCUMENTED_FRAMES] + for event_type, payload in DOCUMENTED_FRAMES: + fired = hass.bus.fired(event_type) + assert fired, f"{event_type} never fired" + assert set(fired[-1]) == DOCUMENTED_FIELDS[event_type] + finally: + await manager.stop() + + +async def test_manager_stop_cancels_task_and_blocks_later_frames(hass) -> None: + """stop() cancels and awaits the task: no frame fires afterwards, + even when the (dead) stream is later offered more data.""" + stream_release = asyncio.Event() + first_type, first_payload = DOCUMENTED_FRAMES[0] + later_type, later_payload = DOCUMENTED_FRAMES[1] + + def _factory(): + async def _gen(): + yield (first_type, dict(first_payload)) + await stream_release.wait() + yield (later_type, dict(later_payload)) + + return _gen() + + client = _ScriptedStreamClient([_factory]) + manager = make_manager(hass, client) + manager.start() + await _wait_until(lambda: len(hass.bus.events) == 1) + + task = manager.task + await manager.stop() + assert task.cancelled() + assert manager.task is None + + # Offer the stream more data after teardown: nothing fires. + stream_release.set() + await _REAL_SLEEP(0.05) + assert hass.bus.events == [(first_type, dict(first_payload))] + + +async def test_manager_reconnects_with_bounded_backoff(hass, monkeypatch) -> None: + """A failed stream reconnects with a bounded, reset-on-success + backoff and never crashes the task.""" + delays: list[float] = [] + + async def fake_sleep(delay, *args, **kwargs): + delays.append(delay) + await _REAL_SLEEP(0) + + monkeypatch.setattr(sse_module.asyncio, "sleep", fake_sleep) + + stream_error = ConnectionError("stream reset") + client = _ScriptedStreamClient( + [ + _fail(RuntimeError("api down")), + _fail(RuntimeError("api down")), + _fail(RuntimeError("api down")), + _fail(RuntimeError("api down")), + _drop_after(stream_error, DOCUMENTED_FRAMES[0]), + _frames(*DOCUMENTED_FRAMES), + ] + ) + manager = make_manager(hass, client, min_backoff=0.05, max_backoff=0.2) + manager.start() + try: + await _wait_until(lambda: len(hass.bus.events) >= 5) + # 5s → 10s → 20s (cap) → 20s (cap), then a frame on a healthy + # stream reset it to the 5s minimum for the next failure. + assert delays == [0.05, 0.1, 0.2, 0.2, 0.05] + assert hass.bus.events == [ + tuple(DOCUMENTED_FRAMES[0]), + *[tuple(f) for f in DOCUMENTED_FRAMES], + ] + # The task is still alive (subscribed and idle), not crashed. + assert manager.task is not None and not manager.task.done() + finally: + await manager.stop() + + +def test_manager_rejects_invalid_backoff_bounds(hass) -> None: + """Non-positive or inverted backoff bounds fail at construction.""" + with pytest.raises(ValueError): + NestQuestEventStream(hass, object(), reconnect_min_seconds=0) + with pytest.raises(ValueError): + NestQuestEventStream(hass, object(), reconnect_min_seconds=-1) + with pytest.raises(ValueError): + NestQuestEventStream(hass, object(), reconnect_max_seconds=0) + with pytest.raises(ValueError): + NestQuestEventStream(hass, object(), reconnect_min_seconds=10.0, reconnect_max_seconds=5.0) + + +# --------------------------------------------------------------------------- +# Integration wiring: setup starts the stream, unload stops it +# --------------------------------------------------------------------------- + + +class _SetupClient: + """Client for full-setup wiring tests: scripted snapshot + stream.""" + + def __init__(self, script) -> None: + self._script = list(script) + self.stream_calls = 0 + + async def get_snapshot(self) -> dict: + return dict(MINIMAL_SNAPSHOT) + + def stream_events(self): + self.stream_calls += 1 + return self._script.pop(0)() + + +async def test_setup_starts_stream_and_unload_stops_it(hass, make_entry) -> None: + """Setup starts the subscription alongside the runtime record; unload + tears the task down so no frame fires afterwards.""" + client = _SetupClient([_frames(*DOCUMENTED_FRAMES)]) + set_coordinator_client(make_entry(), client) + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await nq_integration.async_setup_entry(hass, entry) is True + + runtime = entry.runtime_data + assert runtime.event_stream is not None + await _wait_until(lambda: len(hass.bus.events) >= 4) + assert [event for event, _ in hass.bus.events] == [ + frame[0] for frame in DOCUMENTED_FRAMES + ] + + task = runtime.event_stream.task + assert await nq_integration.async_unload_entry(hass, entry) is True + assert task.cancelled() + assert runtime.event_stream.task is None + + events_after_unload = len(hass.bus.events) + await _REAL_SLEEP(0.05) + assert len(hass.bus.events) == events_after_unload + + +async def test_setup_without_stream_surface_starts_no_subscription( + hass, make_entry +) -> None: + """An entry whose API client has no stream surface (the local-snapshot + stub the harness installs, like the unconfigured-token stub in + production) starts NO subscription — and setup still succeeds.""" + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await nq_integration.async_setup_entry(hass, entry) is True + assert entry.runtime_data.event_stream is None + assert await nq_integration.async_unload_entry(hass, entry) is True -- 2.55.0 From b0bbb5f8c53fbae4eb4f21e35a47396e01aad1e9 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 07:52:32 -0400 Subject: [PATCH 4/7] complete_quest proxies to the API (task 47c7c90e) (#169) --- custom_components/nestquest/services.py | 66 ++++-- tests/conftest.py | 130 ++++++++++- tests/test_complete_quest_proxy.py | 283 ++++++++++++++++++++++++ tests/test_entity_matrix.py | 11 +- tests/test_events.py | 142 +++++++++--- 5 files changed, 585 insertions(+), 47 deletions(-) create mode 100644 tests/test_complete_quest_proxy.py diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index d5606c7..37ee8f4 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -6,7 +6,10 @@ call time through the injected ``find_runtime`` callable. Permission checks are Feature 09 later tasks — this module only validates payload shape and forwards to the Feature 03/06/08 business layers (the presence writes included, through :mod:`.presence_management` — the -ONE implementation the API admin plane shares). +ONE implementation the API admin plane shares). Since Feature 18 the +integration is a client of the NestQuest API service, so +``complete_quest`` proxies to the API's panel complete route through +the entry's shared API client instead of writing any database. ``export_history_csv`` is registered with a schema but raises until Feature 13. ``regenerate`` is supplied by the caller so the Feature 07 handler stays the one in ``__init__``. @@ -26,6 +29,7 @@ from . import children as children_layer from . import completion as completion_layer from . import presence_management as presence_management_layer from . import quest_definitions as quest_definitions_layer +from .api_client import NestQuestApiError from .const import ( DOMAIN, DOMAIN_SERVICES, @@ -42,7 +46,7 @@ from .const import ( SERVICE_UNCOMPLETE_QUEST, SERVICE_UPDATE_QUEST_DEFINITION, ) -from .events import fire_quest_completed, fire_quest_uncompleted +from .events import fire_quest_uncompleted from .permissions import permission_gate from .recurrence import ScheduleRule from .service_policy import SERVICE_POLICY @@ -301,22 +305,50 @@ def _complete_quest( async def _handler(call: Any) -> None: runtime = _require_runtime(hass, find_runtime) instance_id = call.data["instance_id"] - # ``appended`` is decided under the same lock as the write, so - # a concurrent duplicate call (a double tap) can not produce a - # second transition event: the loser appends nothing. - result = await completion_layer.complete_instance( - runtime.database, - instance_id, - **_actor_kwargs(call), - ) - if result.appended: - await fire_quest_completed( - hass, - runtime.database, - instance_id, - was_on_time=result.was_on_time, + # The panel contract (decision 9): a tap completes as the + # tapped child profile, the only actor shape the API's panel + # route accepts. The payload schema is unchanged — the panel + # cards call it with instance_id, actor=panel, actor_child_id. + actor = _actor_kwargs(call) + if actor["actor_source"] != ACTOR_PANEL: + raise HomeAssistantError( + "nestquest.complete_quest completes through the API's " + "panel route as the tapped child profile; actor must " + "be 'panel' with actor_child_id" ) - await _refresh_entities(runtime) + # The entry's ONE shared API client (built at setup): the + # coordinator's snapshot poll, the SSE subscription, and this + # proxy all ride the same instance. An entry with no panel + # token runs the unconfigured stub, which has no completion + # surface — fail fast with a clear, token-free message. + complete_instance = getattr( + getattr(runtime.coordinator, "api_client", None), + "complete_instance", + None, + ) + if complete_instance is None: + raise HomeAssistantError( + "NestQuest API is not configured; cannot complete a " + "quest" + ) + try: + await complete_instance(instance_id, actor["actor_child_id"]) + except NestQuestApiError as err: + # The typed API failure surfaces as a service error the + # caller sees — never swallowed, never retried here. The + # typed message names method, path, and status only; the + # panel token never appears in it. + raise HomeAssistantError( + f"NestQuest quest completion failed: {err}" + ) from err + # NO local completion write and NO local event: the API service + # emits the transition on its SSE stream and the integration's + # subscription re-fires it on the bus — firing it here too + # would double-fire. The immediate refresh keeps the old + # behaviour where the sensors reflect the completion the + # moment the service call returns instead of waiting for the + # poll interval. + await _refresh_entities(runtime) return _with_errors(_handler) diff --git a/tests/conftest.py b/tests/conftest.py index d7e836e..8af8125 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -757,6 +757,16 @@ class LazyLocalSnapshotClient: coordinator reconstructs from a byte-identical payload without any network or FastAPI machinery. + ``complete_instance`` bridges the panel complete route the same + way (the ``nestquest.complete_quest`` proxy calls it through the + API client): it resolves the entry's database and delegates to the + SAME core completion the API route delegates to, with the panel + actor shape, and answers the route's ``{"status": ...}`` body. + Like the real API service it publishes its transition events on + the SSE stream — NOT on the Home Assistant bus and NOT from the + service handler; tests that assert bus events drive them through + the SSE subscription the way production delivers them. + 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 @@ -767,6 +777,29 @@ class LazyLocalSnapshotClient: self._hass = hass self._entry_id = entry_id self.calls = 0 + #: Every ``(instance_id, CompletionResult)`` this stand-in + # produced, in call order — tests read ``appended`` / + # ``was_on_time`` off these to check the completion verdicts. + self.completions = [] + #: The SSE frames the stand-in published for its completions + # (the API service's stream content), drained by + #: :func:`refire_api_transitions`. + self.published_frames = [] + + def _live_runtime(self): + """The entry's runtime record, or ``None`` before/after setup.""" + from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN + + 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 None + return runtime + + def _live_database(self): + """The entry's open database, or ``None`` before/after setup.""" + runtime = self._live_runtime() + return None if runtime is None else runtime.database async def get_snapshot(self): import datetime @@ -776,14 +809,14 @@ class LazyLocalSnapshotClient: 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): + runtime = self._live_runtime() + if runtime is None: return { "today_iso": datetime.date.today().isoformat(), "cycle_day": 0, "children": [], } + database = runtime.database 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 @@ -813,6 +846,44 @@ class LazyLocalSnapshotClient: ], } + async def complete_instance(self, instance_id: int, actor_child_id: int): + """The panel complete route's stand-in: complete through the + SAME core completion the route delegates to, panel actor + shape, publish the route's SSE transition frames (built by the + same core builders, appended completions only — see + ``api/routes_panel.py``), and answer the route's + ``{"status": ...}`` body.""" + import datetime + + from custom_components.nestquest.completion import complete_instance + from custom_components.nestquest.core import events as core_events + + result = await complete_instance( + self._live_database(), + instance_id, + actor_source="panel", + actor_child_id=actor_child_id, + ) + self.completions.append((instance_id, result)) + if result.appended: + # The route publishes the transition the moment the core + # write lands (appended completions only), evaluating the + # day-complete rule against the API host's local date — + # the frames queue here for the SSE stream to carry. + event_type, payload = await core_events.build_quest_completed_event( + self._live_database(), + instance_id, + was_on_time=result.was_on_time, + ) + self.published_frames.append((event_type, payload)) + now = datetime.datetime.now().astimezone() + day_complete = await core_events.build_child_day_complete_event( + self._live_database(), payload, today=now.date() + ) + if day_complete is not None: + self.published_frames.append(day_complete) + return {"status": str(result)} + @pytest.fixture(autouse=True) def coordinator_serves_local_snapshot(): @@ -858,6 +929,59 @@ def set_coordinator_client(entry, client): return client +async def refire_api_transitions(hass, entry, client): + """Deliver the SSE frames the stand-in client has published so far + through the integration's SSE subscription — the production event + path. + + Since Feature 18 the ``complete_quest`` service fires no local + bus event: the API service builds and publishes the transition + frames on its SSE stream, and the integration's subscription + (:mod:`custom_components.nestquest.sse`) re-fires each frame on + the bus. This helper streams the frames the stand-in client + published (``client.published_frames``) through a real + :class:`~custom_components.nestquest.sse.NestQuestEventStream` + and waits (bounded) until the bus has re-fired every one of them. + """ + import asyncio + + from custom_components.nestquest.sse import NestQuestEventStream + + frames, client.published_frames = client.published_frames, [] + + class _ScriptedStream: + """A healthy stream: the frames, then it stays open.""" + + def __init__(self, frames) -> None: + self._frames = frames + + def stream_events(self): + async def _gen(): + for frame in self._frames: + yield frame + await asyncio.Event().wait() + + return _gen() + + before = {etype: len(hass.bus.fired(etype)) for etype, _ in frames} + expected = len(frames) + manager = NestQuestEventStream(hass, _ScriptedStream(frames)) + manager.start() + try: + async with asyncio.timeout(2): + while True: + delivered = sum( + len(hass.bus.fired(etype)) - before[etype] + for etype, _ in frames + ) + if delivered >= expected: + break + await asyncio.sleep(0.01) + finally: + await manager.stop() + return frames + + class _HassNamespace(SimpleNamespace): """SimpleNamespace subclass so the hass stub can hold weak references.""" diff --git a/tests/test_complete_quest_proxy.py b/tests/test_complete_quest_proxy.py new file mode 100644 index 0000000..3aca922 --- /dev/null +++ b/tests/test_complete_quest_proxy.py @@ -0,0 +1,283 @@ +"""The ``nestquest.complete_quest`` proxy to the API client (Feature 18). + +The service is a thin proxy: a panel tap must reach the API's panel +complete route through the entry's ONE shared API client (the same +instance the coordinator polls and the SSE subscription reads), with +exactly the payload contract the panel cards send (``instance_id``, +``actor=panel``, ``actor_child_id``). The service performs NO local +database completion and fires NO local bus event — the API service +emits the transition on its SSE stream and the integration's +subscription re-fires it (firing it locally too would double-fire) — +and it refreshes the coordinator after a successful call so the +sensors reflect the completion immediately. Every failure (a typed +:class:`NestQuestApiError`, an unconfigured client, a non-panel +actor) surfaces as a :class:`HomeAssistantError`, token-free. + +The scripted clients here stand in for the API service: recording and +failing ``complete_instance`` implementations, no network, no aiohttp. +""" +from __future__ import annotations + +import datetime + +import pytest +from homeassistant.exceptions import HomeAssistantError + +from conftest import ( + LazyLocalSnapshotClient, + set_coordinator_client, + wire_entry_to_registry, +) + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.api_client import NestQuestApiError +from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, + CONF_PANEL_TOKEN, + DOMAIN, + EVENT_CHILD_DAY_COMPLETE, + EVENT_QUEST_COMPLETED, + SERVICE_COMPLETE_QUEST, +) +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import ( + create_quest_definition, +) +from custom_components.nestquest.recurrence import ScheduleRule + +ADMIN_CTX = {"user_id": "admin-1"} + + +class _RecordingApiClient(LazyLocalSnapshotClient): + """Snapshot-only stand-in that RECORDS completion calls. + + ``complete_instance`` deliberately performs NO database write: the + API service is remote, and the assertions below must be able to + prove the service handler itself completed nothing locally. + """ + + def __init__(self, hass, entry_id: str) -> None: + super().__init__(hass, entry_id) + self.complete_calls: list[tuple[int, int]] = [] + + async def complete_instance( + self, instance_id: int, actor_child_id: int + ) -> dict: + self.complete_calls.append((instance_id, actor_child_id)) + return {"status": "done"} + + +class _FailingApiClient(_RecordingApiClient): + """A stand-in whose completion fails the way the client types it.""" + + def __init__(self, hass, entry_id: str, error: NestQuestApiError) -> None: + super().__init__(hass, entry_id) + self._error = error + + async def complete_instance( + self, instance_id: int, actor_child_id: int + ) -> dict: + self.complete_calls.append((instance_id, actor_child_id)) + raise self._error + + +async def _setup_seeded_entry(hass, make_entry, client): + """Wire, script, and set up an entry with one child's daily quest.""" + entry = wire_entry_to_registry( + make_entry( + data={ + CONF_ADMIN_USER_IDS: [ADMIN_CTX["user_id"]], + CONF_PANEL_TOKEN: "secret-panel-token", + } + ), + hass.registry, + ) + set_coordinator_client(entry, client) + assert await async_setup_entry(hass, entry) is True + database = entry.runtime_data.database + from custom_components.nestquest.children import create_child + + child = await create_child(database, "Ada") + today = datetime.date.today() + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today.isoformat()} + ), + [child.id], + ["morning"], + ) + end = (today + datetime.timedelta(days=3)).isoformat() + await materialize(database, today.isoformat(), end, today=today) + instances = await QuestInstancesDao(database).list_by_date_range( + child.id, today.isoformat(), today.isoformat() + ) + assert instances + return entry, client, child, instances[0] + + +def _record_refreshes(entry) -> list[bool]: + """Replace the coordinator's refresh with a recording stand-in.""" + refreshes: list[bool] = [] + + async def _fake_refresh(): + refreshes.append(True) + + entry.runtime_data.coordinator.async_refresh = _fake_refresh + return refreshes + + +async def test_complete_quest_reaches_the_shared_api_client( + hass, make_entry +) -> None: + """A panel tap calls ``complete_instance`` on the entry's shared + client with the tapped child, completes NOTHING locally, fires NO + local event, and refreshes the coordinator immediately.""" + entry, client, child, instance = await _setup_seeded_entry( + hass, make_entry, _RecordingApiClient(hass, "test_entry") + ) + assert entry.runtime_data.coordinator.api_client is client, ( + "the service must proxy through the entry's ONE shared client" + ) + refreshes = _record_refreshes(entry) + + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + + assert client.complete_calls == [(instance.id, child.id)] + # No local completion: the instance is still open in the local + # database — only the API service writes (through its own client). + from custom_components.nestquest.completion import instance_state + + assert ( + await instance_state(entry.runtime_data.database, instance.id) + == "open" + ) + # No local firing: the completed (and day-complete) events arrive + # over the SSE stream, re-fired by the subscription — never from + # this handler. + assert hass.bus.fired(EVENT_QUEST_COMPLETED) == [] + assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] + # The old immediate-update behaviour: the sensors' coordinator + # refresh runs before the service call returns. + assert refreshes == [True] + + +@pytest.mark.parametrize( + ("error", "fragment"), + [ + ( + NestQuestApiError( + "NestQuest API POST /api/v1/panel/instances/7/complete " + "failed with HTTP 401", + status=401, + ), + "failed with HTTP 401", + ), + ( + NestQuestApiError( + "NestQuest API POST /api/v1/panel/instances/7/complete " + "transport failure: connection refused", + status=None, + ), + "transport failure", + ), + ], +) +async def test_api_failure_surfaces_as_a_service_error( + hass, make_entry, error, fragment +) -> None: + """A typed API failure (non-2xx or transport) raises + ``HomeAssistantError`` carrying the typed message — which never + includes the panel token — with nothing completed or fired + locally, and no refresh for a mutation that never happened.""" + entry, client, child, instance = await _setup_seeded_entry( + hass, + make_entry, + _FailingApiClient(hass, "test_entry", error), + ) + refreshes = _record_refreshes(entry) + + with pytest.raises(HomeAssistantError, match=fragment) as excinfo: + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + # The typed error is chained, and the service error carries the + # typed message only — never the panel token. + assert excinfo.value.__cause__ is error + assert "secret-panel-token" not in str(excinfo.value) + assert client.complete_calls == [(instance.id, child.id)] + from custom_components.nestquest.completion import instance_state + + assert ( + await instance_state(entry.runtime_data.database, instance.id) + == "open" + ) + assert hass.bus.fired(EVENT_QUEST_COMPLETED) == [] + assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] + assert refreshes == [] + + +async def test_unconfigured_api_client_surfaces_as_a_service_error( + hass, make_entry +) -> None: + """An entry with no completion surface (the unconfigured-token + stub the coordinator swaps in) fails the service clearly instead + of reaching an attribute error.""" + from custom_components.nestquest.coordinator import _UnconfiguredApiClient + + entry, _client, child, instance = await _setup_seeded_entry( + hass, make_entry, _UnconfiguredApiClient() + ) + + with pytest.raises( + HomeAssistantError, match="NestQuest API is not configured" + ): + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "panel", + "actor_child_id": child.id, + }, + ) + + +async def test_non_panel_actor_is_rejected_before_the_client( + hass, make_entry +) -> None: + """The API's panel route completes as the tapped child only: a + user-actor call fails fast with a clear error and never reaches + the client.""" + entry, client, child, instance = await _setup_seeded_entry( + hass, make_entry, _RecordingApiClient(hass, "test_entry") + ) + + with pytest.raises(HomeAssistantError, match="actor must be 'panel'"): + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + { + "instance_id": instance.id, + "actor": "user", + "actor_user_id": ADMIN_CTX["user_id"], + }, + context=ADMIN_CTX, + ) + assert client.complete_calls == [] diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py index 0452966..b843057 100644 --- a/tests/test_entity_matrix.py +++ b/tests/test_entity_matrix.py @@ -14,7 +14,7 @@ from __future__ import annotations import datetime import re -from conftest import wire_entry_to_registry +from conftest import refire_api_transitions, wire_entry_to_registry from custom_components.nestquest import async_setup_entry from custom_components.nestquest.children import ( @@ -154,6 +154,11 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: "actor_child_id": ada.id, }, ) + # The transition events arrive over the API's SSE stream (the + # subscription re-fires them), never from the service handler. + await refire_api_transitions( + hass, entry, entry.runtime_data.coordinator.api_client + ) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2 for payload in hass.bus.fired(EVENT_QUEST_COMPLETED): assert _TIMESTAMP.match(payload["occurred_at"]) @@ -234,6 +239,10 @@ async def test_payload_schema_matches_the_documented_contract( "actor_child_id": ada.id, }, ) + # The completed event arrives over the API's SSE stream. + await refire_api_transitions( + hass, entry, entry.runtime_data.coordinator.api_client + ) payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0] for key, check in ( ("child_id", lambda v: isinstance(v, int)), diff --git a/tests/test_events.py b/tests/test_events.py index 77977b9..5e03a45 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,9 +1,21 @@ -"""Tests for the NestQuest bus events (Feature 10, task 6).""" +"""Tests for the NestQuest bus events (Feature 10, task 6). + +Since Feature 18 the integration is a client of the NestQuest API +service: ``nestquest.complete_quest`` proxies to the API's panel +complete route and fires NO local bus event — the API service builds +and publishes the transition frames on its SSE stream, and the +integration's subscription (:mod:`custom_components.nestquest.sse`) +re-fires them. The service-driven tests below therefore deliver the +frames through :func:`conftest.refire_api_transitions` (the +production event path, driven synchronously) before asserting the +bus; the shim-level tests drive :mod:`custom_components.nestquest +.events` directly, the way its remaining callers do. +""" from __future__ import annotations import re -from conftest import wire_entry_to_registry +from conftest import refire_api_transitions, wire_entry_to_registry from custom_components.nestquest import async_setup_entry from custom_components.nestquest.children import create_child @@ -86,6 +98,11 @@ async def test_quest_completed_fires_with_documented_payload( SERVICE_COMPLETE_QUEST, {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, ) + # The completed event arrives over the API's SSE stream (the + # subscription re-fires it), never from the service handler. + await refire_api_transitions( + hass, entry, entry.runtime_data.coordinator.api_client + ) fired = hass.bus.fired(EVENT_QUEST_COMPLETED) assert len(fired) == 1 payload = fired[0] @@ -111,6 +128,15 @@ async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None: SERVICE_COMPLETE_QUEST, {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, ) + # The no-op verdict is decided in the API's completion layer: the + # second call appended nothing, so the API published no second + # frame and the bus stays at exactly one event. + client = entry.runtime_data.coordinator.api_client + assert [result.appended for _id, result in client.completions] == [ + True, + False, + ] + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 @@ -128,6 +154,9 @@ async def test_child_day_complete_fires_when_day_clears( "actor_child_id": child.id, }, ) + await refire_api_transitions( + hass, entry, entry.runtime_data.coordinator.api_client + ) day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) assert len(day_complete) == 1, "exactly one day-complete per cleared day" payload = day_complete[0] @@ -153,6 +182,12 @@ async def test_child_day_complete_does_not_fire_partway( "actor_child_id": child.id, }, ) + # The API publishes the completion but not a day-complete (the + # child's day is not clear yet); the subscription re-fires only + # what was published. + await refire_api_transitions( + hass, entry, entry.runtime_data.coordinator.api_client + ) assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] await hass.services.call( DOMAIN, @@ -163,6 +198,9 @@ async def test_child_day_complete_does_not_fire_partway( "actor_child_id": child.id, }, ) + await refire_api_transitions( + hass, entry, entry.runtime_data.coordinator.api_client + ) assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 @@ -223,6 +261,15 @@ async def test_appended_verdict_is_atomic_under_duplicate_calls( for _ in range(2) ] await asyncio.gather(*calls) + # The API's completion layer (the same core lock) decides the + # verdict: one call appended, the duplicate did not — so the API + # published exactly one frame and the bus carries one event. + client = entry.runtime_data.coordinator.api_client + assert len(client.completions) == 2 + assert [result.appended for _id, result in client.completions].count( + True + ) == 1 + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 @@ -263,6 +310,9 @@ async def test_day_complete_ignored_for_backdated_completion( "actor_child_id": child.id, }, ) + await refire_api_transitions( + hass, entry, entry.runtime_data.coordinator.api_client + ) assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 await _complete( @@ -280,12 +330,19 @@ async def test_day_complete_ignored_for_backdated_completion( "actor_child_id": child.id, }, ) + # The service call on the already-done future instance is a no-op + # inside the API's completion layer: nothing appended, nothing + # published, nothing re-fired — today's single announcement stands. + client = entry.runtime_data.coordinator.api_client + assert client.completions[-1][1].appended is False + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, ( "a back-dated/future completion must not re-announce today" ) - # The direct business-layer call fired nothing (only the service - # path fires), and the follow-up service call on the now-done - # future instance was a no-op: today's single completion stands. + # The direct business-layer call published nothing (only the API's + # completion path publishes), and the follow-up service call on + # the now-done future instance was a no-op: today's single + # completion stands. assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances) @@ -324,6 +381,10 @@ async def test_completion_updates_entities_immediately( assert remaining_after == len(instances) - 1, ( "sensor must reflect the completion without a manual refresh" ) + # The transition events arrive over the API's SSE stream. + await refire_api_transitions( + hass, entry, entry.runtime_data.coordinator.api_client + ) assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == ( 1 if len(instances) == 1 else 0 ) @@ -538,17 +599,32 @@ async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> Non """The shim fires ``nestquest_quest_completed`` BEFORE ``nestquest_child_day_complete`` (the pre-extraction ordering), so a failure in the day-complete DB reads cannot suppress the - completed event. Asserted by bus event ORDER, not just counts.""" + completed event. Asserted by bus event ORDER, not just counts. + + Driven directly: since Feature 18 the completion service proxies + to the API (whose event ordering is tested in test_api_sse.py); + this targets the shim module itself, the way its remaining + callers invoke it. + """ + from custom_components.nestquest.completion import ( + complete_instance as _complete, + ) + from custom_components.nestquest.events import fire_quest_completed + entry, child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database for instance in instances: - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance.id, - "actor": "panel", - "actor_child_id": child.id, - }, + result = await _complete( + database, + instance.id, + actor_source="panel", + actor_child_id=child.id, + ) + await fire_quest_completed( + hass, + database, + instance.id, + was_on_time=result.was_on_time, ) ordered = [ etype for etype, _payload in hass.bus.events @@ -732,21 +808,31 @@ async def test_day_complete_fully_cleared_today_yields_event( async def test_shim_day_complete_uses_ha_local_timezone( hass, make_entry ) -> None: - """The day-complete rule reads ``hass.config.time_zone`` and - derives ``today`` in HA-local time, not UTC. + """The shim's day-complete rule reads ``hass.config.time_zone`` + and derives ``today`` in HA-local time, not UTC. Picks a zone whose current local date differs from UTC, seeds an - instance for THAT zone's today (not UTC's today), completes it - through the service path, and asserts + instance for THAT zone's today (not UTC's today), fires the shim + directly for a completion of that instance, and asserts ``nestquest_child_day_complete`` fires — proving the local date reached the day-complete rule. If no candidate zone has a different date right now (only possible inside the ~1h window around 12:00 UTC when all zones share one date), the test skips rather than run vacuously. + + Driven directly: since Feature 18 the completion service proxies + to the API (whose day-complete ``today`` is the API host's local + date, tested in test_api_sse.py); this targets the shim module + itself, the way its remaining callers invoke it. """ import datetime as _dt from zoneinfo import ZoneInfo + from custom_components.nestquest.completion import ( + complete_instance as _complete, + ) + from custom_components.nestquest.events import fire_quest_completed + utc_today = _dt.datetime.now(_dt.timezone.utc).date() far_zone = None far_today = None @@ -775,15 +861,19 @@ async def test_shim_day_complete_uses_ha_local_timezone( entry, child, instances = await _setup_and_seed( hass, make_entry, seed_date=far_today ) + database = entry.runtime_data.database for instance in instances: - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance.id, - "actor": "panel", - "actor_child_id": child.id, - }, + result = await _complete( + database, + instance.id, + actor_source="panel", + actor_child_id=child.id, + ) + await fire_quest_completed( + hass, + database, + instance.id, + was_on_time=result.was_on_time, ) day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) assert len(day_complete) == 1, ( -- 2.55.0 From 684f52ece8addca7e21a2402358d6e846d94b633 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 08:08:06 -0400 Subject: [PATCH 5/7] Task d25b875f: Last-good snapshot cache and staleness handling (#170) --- custom_components/nestquest/__init__.py | 5 + custom_components/nestquest/coordinator.py | 91 +++++++- custom_components/nestquest/core/const.py | 15 ++ custom_components/nestquest/options_flow.py | 58 ++++- custom_components/nestquest/strings.json | 2 + .../nestquest/translations/en.json | 2 + tests/test_coordinator.py | 205 +++++++++++++++++- tests/test_options_flow.py | 114 ++++++++++ 8 files changed, 478 insertions(+), 14 deletions(-) diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 96cbb41..50bc278 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -18,7 +18,9 @@ from homeassistant.helpers.event import async_track_time_change from .const import ( CONF_ADMIN_USER_IDS, + CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, @@ -518,6 +520,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval_seconds=entry.options.get( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL ), + staleness_seconds=entry.options.get( + CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS + ), ) # The panel event subscription (the SSE task): a background # task consumes the API service's transition-event stream and diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 1d634e1..22cbb24 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -43,11 +43,24 @@ 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.) + +Last-good cache and staleness: every successful pass refreshes a cache +of the rebuilt snapshot stamped with the :func:`time.monotonic` +instant of the success. A typed failure is checked against that stamp +BEFORE the pass is declared failed: within the configured staleness +threshold (``CONF_SNAPSHOT_STALENESS``, seconds, default three update +intervals) the pass SUCCEEDS with the cached snapshot — the panel +keeps rendering last-good data and the entities stay available — and +the outage is logged as a warning. Entities go unavailable only once +the cache is older than the threshold (or before the first success); +a later successful fetch refreshes the cache and the entities recover +on their own, without a config-entry reload. """ from __future__ import annotations import asyncio import datetime +import time from typing import Any from homeassistant.core import HomeAssistant @@ -59,6 +72,7 @@ from homeassistant.helpers.update_coordinator import ( from .api_client import NestQuestApiClient, NestQuestApiError, resolve_api_config from .const import ( CONF_UPDATE_INTERVAL, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DOMAIN, LOGGER, @@ -176,6 +190,17 @@ class _UnconfiguredApiClient: ) +def _monotonic() -> float: + """The monotonic clock the staleness math reads. + + A one-line indirection over :func:`time.monotonic` so the staleness + tests can drive the clock deterministically (patch THIS function, + never the stdlib clock). Monotonic is deliberate: wall-clock jumps + (NTP corrections, suspends) must not shorten or extend the window. + """ + return time.monotonic() + + class NestQuestCoordinator(DataUpdateCoordinator): """Shared refresh cycle for every NestQuest entity. @@ -199,6 +224,15 @@ class NestQuestCoordinator(DataUpdateCoordinator): newer one and leave entities wrong until the next poll. Real HA's coordinator already serializes internally; the lock keeps the same guarantee on the stand-in and costs nothing upstream. + + Last-good cache: a successful pass stamps the rebuilt snapshot + with the :func:`~.coordinator._monotonic` instant of the success; + a typed failure within the configured staleness threshold + (``staleness_seconds``, from ``CONF_SNAPSHOT_STALENESS``) serves + that cached snapshot instead of failing the pass, so a short API + outage keeps the board up. Past the threshold — or before the + first success — the pass fails as above, and a later successful + pass refreshes the cache and recovers the entities on its own. """ def __init__( @@ -208,6 +242,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): entry_id: str, api_client: NestQuestApiClient, update_interval_seconds: int | None = None, + staleness_seconds: int | None = None, ) -> None: interval = update_interval_seconds if ( @@ -216,6 +251,19 @@ class NestQuestCoordinator(DataUpdateCoordinator): or interval < MIN_UPDATE_INTERVAL ): interval = DEFAULT_UPDATE_INTERVAL + staleness = staleness_seconds + if ( + not isinstance(staleness, int) + or isinstance(staleness, bool) + or staleness < interval + ): + # A missing or nonsensical threshold falls back to the + # shipped default; a configured-but-too-short one (below + # even one poll interval) falls back to whichever of + # default/interval spans at least one failed poll, so the + # cache is never dead on arrival. (The options flow + # validates the same rule at the form.) + staleness = max(DEFAULT_SNAPSHOT_STALENESS, interval) super().__init__( hass, LOGGER, @@ -225,6 +273,14 @@ class NestQuestCoordinator(DataUpdateCoordinator): self.entry_id = entry_id self.api_client = api_client self._refresh_lock = asyncio.Lock() + #: The configured last-good window, in SECONDS (const.py: + #: CONF_SNAPSHOT_STALENESS / DEFAULT_SNAPSHOT_STALENESS). + self._staleness_seconds = staleness + #: The last-good cache: the snapshot from the most recent + #: SUCCESSFUL fetch and the :func:`time.monotonic` instant it + #: landed. Both stay None until the first success. + self._last_good_snapshot: NestQuestSnapshot | None = None + self._last_good_monotonic: float | None = None async def async_refresh(self) -> None: """One refresh at a time; a queued refresh runs AFTER the @@ -236,8 +292,19 @@ class NestQuestCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> NestQuestSnapshot: """One refresh pass: fetch the panel snapshot and rebuild it. + A successful pass rebuilds the snapshot and refreshes the + last-good cache (the snapshot plus the + :func:`time.monotonic` instant of the success). + A typed API failure (unconfigured token stub, transport - failure, non-2xx response) is converted to + failure, non-2xx response) is first checked against that + stamp: WITHIN the configured staleness threshold (elapsed time + since the last successful fetch, inclusive of the threshold + itself) the pass SUCCEEDS with the cached snapshot — the panel + keeps rendering last-good data, the entities stay available — + and the outage is logged as a clear warning (never silent). + With no cache yet, or past the threshold, the typed error 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 @@ -248,11 +315,31 @@ class NestQuestCoordinator(DataUpdateCoordinator): try: payload = await self.api_client.get_snapshot() except NestQuestApiError as err: + now = _monotonic() + cached = self._last_good_snapshot + fetched_at = self._last_good_monotonic + if ( + cached is not None + and fetched_at is not None + and now - fetched_at <= self._staleness_seconds + ): + LOGGER.warning( + "NestQuest API refresh failed (%s); serving the " + "last-good snapshot fetched %.0f s ago, within the " + "%d s staleness threshold", + err, + now - fetched_at, + self._staleness_seconds, + ) + return cached LOGGER.error( "NestQuest coordinator refresh failed: %s", err ) raise UpdateFailed(str(err)) from err - return snapshot_from_api_payload(payload) + snapshot = snapshot_from_api_payload(payload) + self._last_good_snapshot = snapshot + self._last_good_monotonic = _monotonic() + return snapshot def coordinator_client_from_entry( diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index dfecc08..37b949d 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -81,6 +81,21 @@ CONF_UPDATE_INTERVAL = "update_interval" DEFAULT_UPDATE_INTERVAL = 300 MIN_UPDATE_INTERVAL = 30 +# Last-good snapshot staleness (Feature 18), in SECONDS: how long the +# coordinator keeps SERVING the last good snapshot after the API stops +# answering. A failed poll whose elapsed time since the last successful +# fetch is within the threshold returns the cached snapshot (the panel +# keeps rendering last-good data, entities stay available); past it the +# poll fails the HA way and the entities go unavailable. The default is +# 3x the default update interval (900 s = 15 minutes): three consecutive +# failed five-minute polls before the board is declared stale — enough +# to ride out an API service restart, short enough not to render a +# days-old board all day. Validation floor: the threshold must be at +# least the configured update interval, or the cache could never span +# even one failed poll. +CONF_SNAPSHOT_STALENESS = "snapshot_staleness" +DEFAULT_SNAPSHOT_STALENESS = 3 * DEFAULT_UPDATE_INTERVAL + # Notification automations (Feature 11). The notify target is a free-text # Home Assistant service name (e.g. "notify.mobile_app_dads_phone") the # household fills in — never a hard-coded personal target; the times are diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 26cdcb9..29adee9 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -23,6 +23,7 @@ from .const import ( CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_PANEL_TOKEN, + CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_API_BASE_URL, DEFAULT_AFTERNOON_REMINDER_TIME, @@ -33,6 +34,7 @@ from .const import ( DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_TOKEN, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DOMAIN, MIN_UPDATE_INTERVAL, @@ -50,8 +52,18 @@ def _parse_time(value: str) -> bool: return TIME_PATTERN.fullmatch(value) is not None -def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: - """Validate user input and return field-level errors instead of raising.""" +def _async_validate( + user_input: dict[str, Any], + current: dict[str, Any] | None = None, +) -> dict[str, str]: + """Validate user input and return field-level errors instead of raising. + + ``current`` carries the stored values (entry options → entry data → + defaults) so a raw submit that sets one field but omits a sibling it + is validated against (the staleness threshold vs. the update + interval) can still check the effective pair; raw submits omitting + BOTH keys keep their stored values untouched. + """ errors: dict[str, str] = {} horizon_days = user_input.get(CONF_HORIZON_DAYS) @@ -86,6 +98,33 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: ): errors[CONF_UPDATE_INTERVAL] = "invalid" + # The last-good staleness threshold (Feature 18) is OPTIONAL in raw + # submits the same way: a missing key keeps the current value. It + # must be a positive int, and at least the EFFECTIVE update interval + # (the one this submit carries, else the stored or default one) — a + # threshold shorter than the poll cadence could never serve even one + # cached snapshot. + staleness = user_input.get(CONF_SNAPSHOT_STALENESS) + if staleness is not None and ( + isinstance(staleness, bool) + or not isinstance(staleness, int) + or staleness < 1 + ): + errors[CONF_SNAPSHOT_STALENESS] = "invalid" + elif staleness is not None and not errors.get(CONF_UPDATE_INTERVAL): + stored_interval = (current or {}).get(CONF_UPDATE_INTERVAL) + effective_interval = update_interval + if effective_interval is None and ( + isinstance(stored_interval, int) + and not isinstance(stored_interval, bool) + and stored_interval >= MIN_UPDATE_INTERVAL + ): + effective_interval = stored_interval + if effective_interval is None: + effective_interval = DEFAULT_UPDATE_INTERVAL + if staleness < effective_interval: + errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness" + # The notification section (Feature 11) is OPTIONAL in raw submits # the same way: a missing key keeps the current value. The three # times are strict HH:MM; the notify target is a non-empty HA @@ -179,6 +218,14 @@ def _build_schema( vol.Required( CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] ): int, + # The last-good staleness threshold (Feature 18): how long the + # coordinator keeps serving the cached snapshot after the API + # stops answering. Schema-validated submits always carry it + # (the default pre-fills from the stored value); raw callers + # omitting it keep their stored value (see _async_validate). + vol.Required( + CONF_SNAPSHOT_STALENESS, default=current[CONF_SNAPSHOT_STALENESS] + ): int, vol.Required( CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET] ): str, @@ -291,7 +338,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): ), ) - errors = _async_validate(user_input) + errors = _async_validate(user_input, self._current_values()) # The admin ids are validated only when the key is present: # schema-validated submits always carry it (vol.Optional's # default fills from the current allowlist), while raw callers @@ -354,6 +401,10 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): CONF_UPDATE_INTERVAL, self._current_values()[CONF_UPDATE_INTERVAL], ), + CONF_SNAPSHOT_STALENESS: user_input.get( + CONF_SNAPSHOT_STALENESS, + self._current_values()[CONF_SNAPSHOT_STALENESS], + ), } # The notification section (Feature 11) stores the household's # choices the same fall-back-to-current way, so raw submits @@ -398,6 +449,7 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), (CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), + (CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS), (CONF_NOTIFY_TARGET, ""), (CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME), (CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME), diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index 3e6312a..1853eb4 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -23,6 +23,7 @@ "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", + "snapshot_staleness": "Last-good snapshot staleness (seconds)", "api_base_url": "NestQuest API base URL (http://host:port)", "panel_token": "Panel service token", "admin_user_ids": "Admin users (parents)" @@ -33,6 +34,7 @@ "invalid": "Invalid value.", "invalid_time": "Time must use 24-hour HH:MM format.", "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", + "invalid_staleness": "Last-good snapshot staleness must be at least the update interval.", "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", "invalid_admin": "Invalid admin selection." } diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index 3e6312a..1853eb4 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -23,6 +23,7 @@ "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", + "snapshot_staleness": "Last-good snapshot staleness (seconds)", "api_base_url": "NestQuest API base URL (http://host:port)", "panel_token": "Panel service token", "admin_user_ids": "Admin users (parents)" @@ -33,6 +34,7 @@ "invalid": "Invalid value.", "invalid_time": "Time must use 24-hour HH:MM format.", "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", + "invalid_staleness": "Last-good snapshot staleness must be at least the update interval.", "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", "invalid_admin": "Invalid admin selection." } diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 25dc0a0..e09a8ab 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -12,6 +12,7 @@ cycle day, and the unconfigured / failed-fetch behaviour. from __future__ import annotations import datetime +import logging import pytest @@ -23,7 +24,9 @@ from custom_components.nestquest.api_client import ( NestQuestApiError, ) from custom_components.nestquest.const import ( + CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DOMAIN, ) @@ -67,6 +70,23 @@ class StubSnapshotClient: return outcome +class FakeMonotonic: + """A deterministic stand-in for the staleness clock. + + The coordinator reads elapsed time through + :func:`custom_components.nestquest.coordinator._monotonic`; tests + patch that seam with an instance of this class and advance ``now`` + by whole seconds, so the staleness window is exercised without any + real waiting. + """ + + def __init__(self, start: float = 1000.0) -> None: + self.now = start + + def __call__(self) -> float: + return self.now + + #: 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. @@ -340,13 +360,20 @@ async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> N 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) 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.""" +async def test_coordinator_failed_fetch_marks_unavailable( + hass, make_entry, monkeypatch +) -> None: + """A failed fetch (transport failure or non-2xx — both typed) PAST + the staleness threshold 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. (Within the threshold + the last-good cache serves instead — the staleness tests below.)""" + import custom_components.nestquest.coordinator as coordinator_module + + clock = FakeMonotonic() + monkeypatch.setattr(coordinator_module, "_monotonic", clock) error = NestQuestApiError( "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", status=503, @@ -359,8 +386,11 @@ 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: the update pass converts - # the typed error to UpdateFailed, with the typed error chained. + # The next pass hits the scripted failure AFTER the cache went + # stale — one second past the shipped 900 s default threshold: the + # update pass converts the typed error to UpdateFailed, with the + # typed error chained. + clock.now += DEFAULT_SNAPSHOT_STALENESS + 1 with pytest.raises(UpdateFailed, match="HTTP 503") as excinfo: await coordinator._async_update_data() assert isinstance(excinfo.value.__cause__, NestQuestApiError) @@ -376,6 +406,163 @@ async def test_coordinator_failed_fetch_marks_unavailable(hass, make_entry) -> N assert isinstance(coordinator.last_exception.__cause__, NestQuestApiError) +async def test_coordinator_serves_cached_snapshot_within_staleness( + hass, make_entry, monkeypatch, caplog +) -> None: + """API down WITHIN the staleness threshold: the failed fetch serves + the last-good cache — the pass succeeds with the SAME snapshot, so + ``last_update_success`` stays True (entities remain available) and + the outage is logged as a clear warning, never silently.""" + import custom_components.nestquest.coordinator as coordinator_module + + clock = FakeMonotonic() + monkeypatch.setattr(coordinator_module, "_monotonic", clock) + error = NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", + status=503, + ) + # Setup's first refresh consumes the good snapshot; the two test + # passes hit the scripted failure. + client = StubSnapshotClient(FIXTURE_SNAPSHOT, error, error) + _entry, coordinator = await _wire_entry( + hass, + make_entry, + client, + options={CONF_UPDATE_INTERVAL: 300, CONF_SNAPSHOT_STALENESS: 900}, + ) + # The successful first pass cached the snapshot (stamped at the + # fake clock's start). + first = coordinator.data + assert first is not None + assert coordinator.last_update_success is True + + # The API goes down 900 s after that success — exactly the + # threshold, still WITHIN the inclusive window: the pass serves the + # cached snapshot instead of failing. + clock.now += 900 + with caplog.at_level( + logging.WARNING, logger="custom_components.nestquest" + ): + await coordinator.async_refresh() + assert coordinator.last_update_success is True + assert coordinator.data is first + assert coordinator.data.children[0].child_name == "Ada" + # The fetch WAS attempted (and failed) — the cache is a fallback, + # not a replacement for polling. + assert client.calls == 2 + assert any( + "last-good" in record.getMessage() for record in caplog.records + ) + + +async def test_coordinator_recovers_without_reload_after_outage( + hass, make_entry, monkeypatch +) -> None: + """The full outage arc: within the threshold the cache serves, past + it the entities go unavailable, and when the API comes back the + next pass succeeds with the FRESH snapshot, re-arms the cache, and + the entities recover — without any config-entry reload.""" + import custom_components.nestquest.coordinator as coordinator_module + + clock = FakeMonotonic() + monkeypatch.setattr(coordinator_module, "_monotonic", clock) + outage = NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503", + status=503, + ) + post_recovery_outage = NestQuestApiError( + "NestQuest API GET /api/v1/panel/snapshot failed with HTTP 502", + status=502, + ) + fresh = {**FIXTURE_SNAPSHOT, "cycle_day": 4} + client = StubSnapshotClient( + FIXTURE_SNAPSHOT, outage, outage, fresh, post_recovery_outage + ) + _entry, coordinator = await _wire_entry( + hass, + make_entry, + client, + options={CONF_UPDATE_INTERVAL: 300, CONF_SNAPSHOT_STALENESS: 900}, + ) + first = coordinator.data + assert first.cycle_day == 3 + + # Within the threshold: the outage serves the cache. + clock.now += 300 + await coordinator.async_refresh() + assert coordinator.last_update_success is True + assert coordinator.data is first + + # Past the threshold: the outage fails the pass (unavailable). + clock.now += 901 + await coordinator.async_refresh() + assert coordinator.last_update_success is False + + # The API returns: the next pass succeeds with the fresh snapshot + # and no reload was involved anywhere. + await coordinator.async_refresh() + assert coordinator.last_update_success is True + fresh_data = coordinator.data + assert fresh_data.cycle_day == 4 + assert hass.registry.reloaded == [] + + # The success re-armed the cache with the FRESH stamp: another + # outage within the threshold serves the new snapshot now, not the + # pre-outage one. + clock.now += 300 + await coordinator.async_refresh() + assert coordinator.last_update_success is True + assert coordinator.data is fresh_data + assert client.calls == 5 + + +async def test_setup_creates_coordinator_with_configured_staleness( + hass, make_entry +) -> None: + """The staleness threshold rides the entry options into the + coordinator, alongside the update interval.""" + entry = wire_entry_to_registry( + make_entry( + options={CONF_UPDATE_INTERVAL: 60, CONF_SNAPSHOT_STALENESS: 600} + ), + hass.registry, + ) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert coordinator._staleness_seconds == 600 + + +async def test_setup_staleness_below_interval_falls_back_to_default( + hass, make_entry +) -> None: + """A stored threshold below the update interval (a raw hand-edited + option) never reaches the coordinator as-is: it falls back to the + shipped default, which still spans the configured interval.""" + entry = wire_entry_to_registry( + make_entry( + options={CONF_UPDATE_INTERVAL: 600, CONF_SNAPSHOT_STALENESS: 60} + ), + hass.registry, + ) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + assert coordinator._staleness_seconds == DEFAULT_SNAPSHOT_STALENESS + + +async def test_coordinator_staleness_floor_never_below_interval(hass) -> None: + """A threshold below the interval falls back to whichever of + default/interval is larger, so the cache can always span at least + one failed poll (here the interval exceeds the shipped default).""" + coordinator = NestQuestCoordinator( + hass, + entry_id="floor", + api_client=StubSnapshotClient(), + update_interval_seconds=1800, + staleness_seconds=60, + ) + assert coordinator._staleness_seconds == 1800 + + async def test_failed_first_refresh_does_not_crash_setup( hass, make_entry ) -> None: diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index c2bcdce..b94b319 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -28,6 +28,7 @@ from custom_components.nestquest.const import ( CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_PANEL_TOKEN, + CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_API_BASE_URL, DEFAULT_AFTERNOON_REMINDER_TIME, @@ -35,6 +36,7 @@ from custom_components.nestquest.const import ( DEFAULT_END_OF_DAY_REPORT_TIME, DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_TOKEN, + DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, @@ -59,6 +61,7 @@ VALID_INPUT = { FULL_INPUT = { **VALID_INPUT, CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, + CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS, CONF_NOTIFY_TARGET: "", CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME, CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME, @@ -246,6 +249,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, + CONF_SNAPSHOT_STALENESS: 450, CONF_NOTIFY_TARGET: "notify.mobile_app_test", CONF_MORNING_SUMMARY_TIME: "07:00", CONF_AFTERNOON_REMINDER_TIME: "14:00", @@ -258,6 +262,7 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, + CONF_SNAPSHOT_STALENESS: 450, CONF_NOTIFY_TARGET: "notify.mobile_app_test", CONF_MORNING_SUMMARY_TIME: "07:00", CONF_AFTERNOON_REMINDER_TIME: "14:00", @@ -901,3 +906,112 @@ def test_options_flow_strings_cover_api_section( assert data.get(key), f"Missing field name for '{key}'" assert strings["options"]["errors"]["invalid_url"] assert translations_en["options"] == strings["options"] + + +# --------------------------------------------------------------------------- +# Last-good snapshot staleness section (Feature 18) +# --------------------------------------------------------------------------- + + +def test_options_flow_staleness_below_update_interval_refused() -> None: + """A staleness threshold shorter than the submit's update interval + is refused with the dedicated error: such a threshold could never + serve even one cached snapshot.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: 300, + CONF_SNAPSHOT_STALENESS: 60, + } + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"} + + +def test_options_flow_staleness_below_stored_interval_refused() -> None: + """A raw submit setting the staleness but omitting the update + interval is checked against the STORED interval, not the default.""" + entry = _make_entry(options={CONF_UPDATE_INTERVAL: 300}) + flow = _make_flow(entry) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_SNAPSHOT_STALENESS: 60}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid_staleness"} + + +def test_options_flow_staleness_must_be_positive_int() -> None: + """Non-int, boolean, and non-positive staleness values reject with + the plain invalid error.""" + flow = _make_flow(_make_entry()) + for bad_staleness in ("900", True, 0, -10): + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_SNAPSHOT_STALENESS: bad_staleness} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_SNAPSHOT_STALENESS: "invalid"} + + +def test_options_flow_staleness_equal_to_interval_saves() -> None: + """A threshold exactly as long as the update interval is valid (the + boundary is inclusive).""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + { + **VALID_INPUT, + CONF_UPDATE_INTERVAL: 300, + CONF_SNAPSHOT_STALENESS: 300, + } + ) + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_SNAPSHOT_STALENESS] == 300 + + +def test_options_flow_staleness_defaults_saved() -> None: + """A bare submit saves the staleness default (three update + intervals) alongside the other timing defaults.""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS + + +def test_options_flow_staleness_stored_values_round_trip() -> None: + """A stored staleness is the form's default and a raw legacy submit + without it keeps the stored value.""" + entry = _make_entry( + options={ + **VALID_INPUT, + CONF_UPDATE_INTERVAL: 300, + CONF_SNAPSHOT_STALENESS: 1200, + } + ) + flow = _make_flow(entry) + form = _run(flow.async_step_init(None)) + schema_defaults = form["data_schema"]({}) + assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200 + + # A raw submit carrying only the legacy keys keeps the stored + # staleness (and never trips the interval cross-check). + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_SNAPSHOT_STALENESS] == 1200 + + +def test_options_flow_strings_cover_staleness_section( + strings: dict, translations_en: dict +) -> None: + """Both string files name the staleness field and its error.""" + data = strings["options"]["step"]["init"]["data"] + assert data.get(CONF_SNAPSHOT_STALENESS), ( + f"Missing field name for '{CONF_SNAPSHOT_STALENESS}'" + ) + assert strings["options"]["errors"]["invalid_staleness"] + assert translations_en["options"] == strings["options"] -- 2.55.0 From 2c4113ed610593a21d35ddd5b5f5abb9f4a291f0 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 08:29:01 -0400 Subject: [PATCH 6/7] Task d04f5bf3: Remove the HA admin services and permission gate (#171) --- custom_components/nestquest/__init__.py | 54 +- custom_components/nestquest/core/const.py | 35 +- .../nestquest/core/service_policy.py | 46 -- custom_components/nestquest/events.py | 6 +- custom_components/nestquest/permissions.py | 90 --- custom_components/nestquest/service_policy.py | 11 - custom_components/nestquest/services.py | 499 +---------------- custom_components/nestquest/services.yaml | 295 +--------- tests/test_entity_matrix.py | 33 +- tests/test_events.py | 93 ++-- tests/test_lifecycle.py | 133 +---- tests/test_permission_matrix.py | 238 -------- tests/test_permissions.py | 122 ----- tests/test_service_policy.py | 32 -- tests/test_services.py | 512 ++---------------- 15 files changed, 157 insertions(+), 2042 deletions(-) delete mode 100644 custom_components/nestquest/core/service_policy.py delete mode 100644 custom_components/nestquest/permissions.py delete mode 100644 custom_components/nestquest/service_policy.py delete mode 100644 tests/test_permission_matrix.py delete mode 100644 tests/test_permissions.py delete mode 100644 tests/test_service_policy.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 50bc278..09da9ef 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -282,9 +282,10 @@ async def _run_horizon_materialization( The walk itself runs through :func:`~.materialize.materialize`, whose DB access is executor-only. - This is the ONE generation path: the startup backfill, the daily rollover - listener and the ``regenerate`` service all funnel through it, so there is - no duplicated materialization logic. ``settings`` (REQUIRED — there is no + This is the ONE generation path inside the integration: the + startup backfill and the daily rollover listener both funnel + through it, so there is no duplicated materialization logic. + ``settings`` (REQUIRED — there is no silent default; every caller already passes the entry's validated :class:`NestQuestSettings`) carries the configured ``horizon_days``. The window itself comes from :meth:`settings.horizon_window` so the helper is @@ -326,13 +327,10 @@ def _register_day_rollover_listener( def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None: """Return a live runtime record with a connected database, or None. - The domain-global services (e.g. ``regenerate``) have no database of - their own; they resolve whichever config entry's runtime data is - currently live at call time, so none of them can hold a stale handle - to a closed connection after another entry unloads. The record also - carries that entry's validated ``settings`` (e.g. the configured - horizon) so service handlers read horizon days off it rather than - re-reading HA config. + The domain-global service (the panel completion proxy) has no + database of its own; it resolves whichever config entry's runtime + data is currently live at call time, so it cannot hold a stale + handle to a closed connection after another entry unloads. """ for runtime_data in hass.data.get(DOMAIN, {}).values(): database = getattr(runtime_data, "database", None) @@ -341,39 +339,17 @@ def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None: return None -def _make_regenerate_handler(hass: HomeAssistant): - """Return the Feature 07 ``regenerate`` handler closed over ``hass``.""" - - async def _regenerate(_call: Any) -> None: - runtime_data = _find_live_runtime_data(hass) - if runtime_data is None: - _LOGGER.warning( - "NestQuest regenerate requested but no config entry has a " - "live database; ignoring" - ) - return - await _run_horizon_materialization( - hass, runtime_data.database, runtime_data.settings - ) - - return _regenerate - - def _register_services(hass: HomeAssistant) -> None: - """Register domain-global NestQuest services once. + """Register the domain-global NestQuest service once. - Services are DOMAIN-global — registered exactly once (guarded by - ``has_service`` on regenerate) and NOT bound to any config entry — - so each handler resolves the currently-live database at call time + The service is DOMAIN-global — registered exactly once (guarded by + ``has_service``) and NOT bound to any config entry — so the panel + completion proxy resolves the currently-live runtime at call time and a name collision or duplicate setup is impossible. Removal is handled by the unload path only once the LAST entry is removed (see :func:`_deregister_services`). """ - async_register_services( - hass, - find_runtime=_find_live_runtime_data, - regenerate_handler=_make_regenerate_handler(hass), - ) + async_register_services(hass, find_runtime=_find_live_runtime_data) def _deregister_services(hass: HomeAssistant) -> None: @@ -483,8 +459,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Backfill any days the daily listener missed while the integration # was off (or freshly installed): run the one idempotent walk over # [today, today + horizon_days] once at setup, using the entry's - # configured horizon. This runs through the SAME path as the daily - # listener and regenerate service. + # configured horizon. This runs through the SAME path as the + # daily listener. await _run_horizon_materialization(hass, database, settings) # The missed sweep runs at startup too (Feature 11): if HA was # down at the rollover time, the previous night's still-open diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index 37b949d..4faadd7 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -20,40 +20,25 @@ TIME_PATTERN = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d") PLATFORMS: list[str] = ["sensor", "binary_sensor"] # Home Assistant bus events (design/ENTITIES-AND-SERVICES.md §3). The -# panel and admin cards listen for these; Feature 10 fires completed, -# uncompleted, and child_day_complete from the service paths, and -# Feature 11's nightly sweep fires quest_missed. +# panel and admin cards listen for these; since Feature 18 the +# completed/uncompleted/day-complete transitions are published by the +# API service on its SSE stream and re-fired on the bus by the +# integration's subscription, and Feature 11's nightly sweep fires +# quest_missed. EVENT_QUEST_COMPLETED = "nestquest_quest_completed" EVENT_QUEST_UNCOMPLETED = "nestquest_quest_uncompleted" EVENT_QUEST_MISSED = "nestquest_quest_missed" EVENT_CHILD_DAY_COMPLETE = "nestquest_child_day_complete" -#: Canonical domain-global service names (design/ENTITIES-AND-SERVICES.md §2 -#: plus the existing Feature 07 regenerate service). +#: Canonical domain-global service name (design/ENTITIES-AND-SERVICES.md §2). +#: Since Feature 18 only the panel completion service remains: the admin +#: surface is the PWA and the API service's admin routes, so the former +#: HA admin services (uncomplete, quest-definition and presence +#: management, CSV export, child management, regenerate) are gone. SERVICE_COMPLETE_QUEST = "complete_quest" -SERVICE_UNCOMPLETE_QUEST = "uncomplete_quest" -SERVICE_CREATE_QUEST_DEFINITION = "create_quest_definition" -SERVICE_UPDATE_QUEST_DEFINITION = "update_quest_definition" -SERVICE_SET_QUEST_DEFINITION_ACTIVE = "set_quest_definition_active" -SERVICE_SET_PRESENCE_PATTERN = "set_presence_pattern" -SERVICE_CREATE_PRESENCE_OVERRIDE = "create_presence_override" -SERVICE_DELETE_PRESENCE_OVERRIDE = "delete_presence_override" -SERVICE_EXPORT_HISTORY_CSV = "export_history_csv" -SERVICE_MANAGE_CHILD = "manage_child" -SERVICE_REGENERATE = "regenerate" DOMAIN_SERVICES: tuple[str, ...] = ( SERVICE_COMPLETE_QUEST, - SERVICE_UNCOMPLETE_QUEST, - SERVICE_CREATE_QUEST_DEFINITION, - SERVICE_UPDATE_QUEST_DEFINITION, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - SERVICE_SET_PRESENCE_PATTERN, - SERVICE_CREATE_PRESENCE_OVERRIDE, - SERVICE_DELETE_PRESENCE_OVERRIDE, - SERVICE_EXPORT_HISTORY_CSV, - SERVICE_MANAGE_CHILD, - SERVICE_REGENERATE, ) SQLITE_DB_FILENAME = "nestquest.db" diff --git a/custom_components/nestquest/core/service_policy.py b/custom_components/nestquest/core/service_policy.py deleted file mode 100644 index 823bed4..0000000 --- a/custom_components/nestquest/core/service_policy.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Explicit admin-only versus open service registry (Feature 09). - -One mapping, one file: every registered ``nestquest`` service name is -either ``open`` or ``admin_only``. The permission decorator (later -Feature 09 tasks) reads this table; it does not take per-handler -policy arguments. -""" -from __future__ import annotations - -from .const import ( - DOMAIN_SERVICES, - SERVICE_COMPLETE_QUEST, - SERVICE_CREATE_PRESENCE_OVERRIDE, - SERVICE_CREATE_QUEST_DEFINITION, - SERVICE_DELETE_PRESENCE_OVERRIDE, - SERVICE_EXPORT_HISTORY_CSV, - SERVICE_MANAGE_CHILD, - SERVICE_REGENERATE, - SERVICE_SET_PRESENCE_PATTERN, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - SERVICE_UNCOMPLETE_QUEST, - SERVICE_UPDATE_QUEST_DEFINITION, -) - -OPEN = "open" -ADMIN_ONLY = "admin_only" - -SERVICE_POLICY: dict[str, str] = { - SERVICE_COMPLETE_QUEST: OPEN, - SERVICE_UNCOMPLETE_QUEST: ADMIN_ONLY, - SERVICE_CREATE_QUEST_DEFINITION: ADMIN_ONLY, - SERVICE_UPDATE_QUEST_DEFINITION: ADMIN_ONLY, - SERVICE_SET_QUEST_DEFINITION_ACTIVE: ADMIN_ONLY, - SERVICE_SET_PRESENCE_PATTERN: ADMIN_ONLY, - SERVICE_CREATE_PRESENCE_OVERRIDE: ADMIN_ONLY, - SERVICE_DELETE_PRESENCE_OVERRIDE: ADMIN_ONLY, - SERVICE_EXPORT_HISTORY_CSV: ADMIN_ONLY, - SERVICE_MANAGE_CHILD: ADMIN_ONLY, - SERVICE_REGENERATE: ADMIN_ONLY, -} - -if frozenset(SERVICE_POLICY) != frozenset(DOMAIN_SERVICES): - raise RuntimeError( - "SERVICE_POLICY must name exactly the registered nestquest services, " - f"got {sorted(SERVICE_POLICY)} vs {list(DOMAIN_SERVICES)}" - ) diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py index 8384333..5a67c81 100644 --- a/custom_components/nestquest/events.py +++ b/custom_components/nestquest/events.py @@ -4,8 +4,10 @@ The transition-event payload construction lives in :mod:`custom_components.nestquest.core.events` (extracted in task 510c1f78) and is HA-free: it builds and RETURNS the events. This shim keeps the integration's old ``fire_quest_completed`` / -``fire_quest_uncompleted`` API (the complete/uncomplete service -handlers call them with ``hass``), reads the HA-local timezone from +``fire_quest_uncompleted`` API for direct callers — since Feature 18 +the HA service surface no longer fires transition events itself (the +API service publishes them on its SSE stream and the integration's +subscription re-fires them) — reads the HA-local timezone from ``hass.config.time_zone`` to derive ``today``, asks the core builder for the events, and fires each on ``hass.bus`` with the documented payloads. The four blueprints keep working with identical payloads. diff --git a/custom_components/nestquest/permissions.py b/custom_components/nestquest/permissions.py deleted file mode 100644 index 5f6df6b..0000000 --- a/custom_components/nestquest/permissions.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Permission gate for NestQuest services (Feature 09). - -A wrapper factory, applied to EVERY registered service at registration -time (no handler is exempt), reads ``call.context.user_id``, consults -the :data:`~.service_policy.SERVICE_POLICY` registry and -:func:`~.admin_allowlist.is_admin`, and raises -:class:`homeassistant.exceptions.Unauthorized` for an admin-only -operation called by a non-admin — including a call with NO user -context, which fails closed. ``complete_quest`` (policy ``open``) -proceeds regardless of the allowlist. - -Logging (later Feature 09 tasks): every denied admin-only call logs a -warning and every successful one logs a debug line, both carrying the -service, the user id (or ``none``), and a UTC timestamp. -""" -from __future__ import annotations - -import datetime -import logging -from collections.abc import Awaitable, Callable -from typing import Any - -from homeassistant.exceptions import Unauthorized - -from .admin_allowlist import is_admin -from .service_policy import ADMIN_ONLY - -LOGGER = logging.getLogger(__name__) - -ServiceHandler = Callable[[Any], Awaitable[None]] - - -def _context_user_id(call: Any) -> str | None: - context = getattr(call, "context", None) - user_id = getattr(context, "user_id", None) if context is not None else None - if isinstance(user_id, str) and user_id: - return user_id - return None - - -def _stamp() -> str: - return datetime.datetime.now(datetime.timezone.utc).isoformat( - timespec="seconds" - ) - - -def permission_gate( - service: str, - policy: str, - database_of: Callable[[Any], Any], -) -> Callable[[ServiceHandler], ServiceHandler]: - """Wrap ``handler`` with the Feature 09 permission check. - - ``database_of`` resolves the live database from the call-time - runtime (the same ``find_runtime`` the handler uses), so the gate - never opens its own connection. The verdict is fail-closed: - absent allowlist row, unknown id, no user context — all not admin. - """ - - def _wrap(handler: ServiceHandler) -> ServiceHandler: - if policy != ADMIN_ONLY: - return handler - - async def _gated(call: Any) -> None: - user_id = _context_user_id(call) - allowed = user_id is not None and await is_admin( - database_of(call), user_id - ) - if not allowed: - LOGGER.warning( - "NestQuest denied %s: caller user_id=%s at %s", - service, - user_id if user_id is not None else "none", - _stamp(), - ) - raise Unauthorized( - f"User '{user_id if user_id is not None else 'none'}' " - f"is not allowed to call nestquest.{service}" - ) - LOGGER.debug( - "NestQuest allowed %s: caller user_id=%s at %s", - service, - user_id, - _stamp(), - ) - await handler(call) - - return _gated - - return _wrap \ No newline at end of file diff --git a/custom_components/nestquest/service_policy.py b/custom_components/nestquest/service_policy.py deleted file mode 100644 index f36d94a..0000000 --- a/custom_components/nestquest/service_policy.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.service_policy` for the integration package. - -The service policy registry lives in -:mod:`custom_components.nestquest.core.service_policy` (extracted in -task 510c1f78); this module re-exports it so existing -``from .service_policy import ...`` references inside the integration -keep working. -""" -from __future__ import annotations - -from .core.service_policy import * # noqa: F401,F403 diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 37ee8f4..ad9f146 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -1,65 +1,36 @@ -"""Domain-global NestQuest service schemas and handlers (Feature 09). +"""The domain-global ``nestquest.complete_quest`` service (Feature 18). Registration is domain-scoped and happens once (see -:func:`async_register_services`); handlers resolve a live database at -call time through the injected ``find_runtime`` callable. Permission -checks are Feature 09 later tasks — this module only validates payload -shape and forwards to the Feature 03/06/08 business layers (the -presence writes included, through :mod:`.presence_management` — the -ONE implementation the API admin plane shares). Since Feature 18 the -integration is a client of the NestQuest API service, so -``complete_quest`` proxies to the API's panel complete route through -the entry's shared API client instead of writing any database. -``export_history_csv`` is registered with a schema but raises until -Feature 13. ``regenerate`` is supplied by the caller so -the Feature 07 handler stays the one in ``__init__``. +:func:`async_register_services`). Since Feature 18 the integration is +a client of the NestQuest API service, so ``complete_quest`` proxies +to the API's panel complete route through the entry's shared API +client instead of writing any database. The former HA admin services +(uncomplete, quest-definition and presence management, CSV export, +child management, regenerate) are removed: the admin surface is the +PWA and the API service's admin routes, so the only HA service left is +the panel completion path. """ from __future__ import annotations -import datetime from collections.abc import Awaitable, Callable from typing import Any -from zoneinfo import ZoneInfo import voluptuous as vol from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import children as children_layer -from . import completion as completion_layer -from . import presence_management as presence_management_layer -from . import quest_definitions as quest_definitions_layer from .api_client import NestQuestApiError from .const import ( DOMAIN, DOMAIN_SERVICES, - QUEST_WINDOWS, SERVICE_COMPLETE_QUEST, - SERVICE_CREATE_PRESENCE_OVERRIDE, - SERVICE_CREATE_QUEST_DEFINITION, - SERVICE_DELETE_PRESENCE_OVERRIDE, - SERVICE_EXPORT_HISTORY_CSV, - SERVICE_MANAGE_CHILD, - SERVICE_REGENERATE, - SERVICE_SET_PRESENCE_PATTERN, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - SERVICE_UNCOMPLETE_QUEST, - SERVICE_UPDATE_QUEST_DEFINITION, ) -from .events import fire_quest_uncompleted -from .permissions import permission_gate -from .recurrence import ScheduleRule -from .service_policy import SERVICE_POLICY FindRuntime = Callable[[HomeAssistant], Any] ServiceHandler = Callable[[Any], Awaitable[None]] ACTOR_PANEL = "panel" ACTOR_USER = "user" -MANAGE_CHILD_CREATE = "create" -MANAGE_CHILD_EDIT = "edit" -MANAGE_CHILD_SET_ACTIVE = "set_active" -MANAGE_CHILD_REORDER = "reorder" def _strict_int(value: object) -> int: @@ -69,82 +40,6 @@ def _strict_int(value: object) -> int: return value -def _strict_bool(value: object) -> bool: - """Reject ints and other truthy values; require a real bool.""" - if type(value) is not bool: - raise vol.Invalid(f"expected bool, got {value!r}") - return value - - -def _weekday_list(value: object) -> list[int]: - if type(value) is not list: - raise vol.Invalid(f"expected a list of weekday ints, got {value!r}") - return [_strict_int(item) for item in value] - - -def _presence_pattern(value: object) -> dict[int, list[int]]: - if not isinstance(value, dict): - raise vol.Invalid(f"pattern must be a mapping, got {value!r}") - pattern: dict[int, list[int]] = {} - for week, days in value.items(): - if isinstance(week, str): - try: - week_index = int(week) - except ValueError as err: - raise vol.Invalid( - f"pattern week index must be an integer, got {week!r}" - ) from err - if str(week_index) != week: - raise vol.Invalid( - f"pattern week index must be an integer, got {week!r}" - ) - else: - week_index = _strict_int(week) - pattern[week_index] = _weekday_list(days) - return pattern - - -def _window_entry(value: object) -> str | tuple[str, str | None]: - if isinstance(value, str): - if value not in QUEST_WINDOWS: - raise vol.Invalid(f"unknown window {value!r}") - return value - if isinstance(value, dict): - parsed = vol.Schema( - { - vol.Required("window"): vol.In(QUEST_WINDOWS), - vol.Optional("due_time"): str, - } - )(value) - due_time = parsed.get("due_time") - if due_time is None: - return parsed["window"] - return (parsed["window"], due_time) - raise vol.Invalid( - f"windows entry must be a window name or object, got {value!r}" - ) - - -def _windows(value: object) -> list[str | tuple[str, str | None]]: - if type(value) is not list: - raise vol.Invalid(f"windows must be a list, got {value!r}") - if not value: - raise vol.Invalid("windows must not be empty") - return [_window_entry(entry) for entry in value] - - -def _int_list(value: object) -> list[int]: - if type(value) is not list: - raise vol.Invalid(f"expected a list of ints, got {value!r}") - return [_strict_int(item) for item in value] - - -def _rule_dict(value: object) -> dict: - if not isinstance(value, dict): - raise vol.Invalid(f"rule must be a dict, got {value!r}") - return value - - SCHEMA_COMPLETE_QUEST = vol.Schema( { vol.Required("instance_id"): _strict_int, @@ -154,86 +49,6 @@ SCHEMA_COMPLETE_QUEST = vol.Schema( } ) -SCHEMA_UNCOMPLETE_QUEST = SCHEMA_COMPLETE_QUEST - -SCHEMA_CREATE_QUEST_DEFINITION = vol.Schema( - { - vol.Required("title"): str, - vol.Required("rule"): _rule_dict, - vol.Required("assignee_child_ids"): vol.All( - _int_list, vol.Length(min=1) - ), - vol.Required("windows"): _windows, - vol.Optional("description"): str, - vol.Optional("icon"): str, - } -) - -SCHEMA_UPDATE_QUEST_DEFINITION = vol.Schema( - { - vol.Required("definition_id"): _strict_int, - vol.Optional("title"): str, - vol.Optional("description"): vol.Any(str, None), - vol.Optional("icon"): vol.Any(str, None), - vol.Optional("rule"): _rule_dict, - vol.Optional("windows"): _windows, - } -) - -SCHEMA_SET_QUEST_DEFINITION_ACTIVE = vol.Schema( - { - vol.Required("definition_id"): _strict_int, - vol.Required("is_active"): _strict_bool, - } -) - -SCHEMA_SET_PRESENCE_PATTERN = vol.Schema( - { - vol.Required("child_id"): _strict_int, - vol.Required("cycle_length_weeks"): _strict_int, - vol.Required("anchor_date"): str, - vol.Required("pattern"): _presence_pattern, - } -) - -SCHEMA_CREATE_PRESENCE_OVERRIDE = vol.Schema( - { - vol.Required("child_id"): _strict_int, - vol.Required("start_date"): str, - vol.Required("end_date"): str, - vol.Required("is_present"): _strict_bool, - vol.Optional("note"): str, - } -) - -SCHEMA_DELETE_PRESENCE_OVERRIDE = vol.Schema( - { - vol.Required("override_id"): _strict_int, - } -) - -SCHEMA_EXPORT_HISTORY_CSV = vol.Schema({}) - -SCHEMA_MANAGE_CHILD = vol.Schema( - { - vol.Required("action"): vol.In( - [ - MANAGE_CHILD_CREATE, - MANAGE_CHILD_EDIT, - MANAGE_CHILD_SET_ACTIVE, - MANAGE_CHILD_REORDER, - ] - ), - vol.Optional("child_id"): _strict_int, - vol.Optional("display_name"): str, - vol.Optional("colour"): str, - vol.Optional("avatar_ref"): str, - vol.Optional("sort_order"): _strict_int, - vol.Optional("is_active"): _strict_bool, - vol.Optional("ordered_ids"): _int_list, - } -) - def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime): runtime = find_runtime(hass) @@ -245,11 +60,11 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime): async def _refresh_entities(runtime: Any) -> None: - """Force an immediate coordinator refresh after a service mutation. + """Force an immediate coordinator refresh after the service call. - Only the SERVICE paths call this — the scheduled materialize and + Only the SERVICE path calls this — the scheduled materialize and rollover internals are direct function calls and never widen this - gate — so entities reflect a completion the moment the service + gate — so the entities reflect a completion the moment the service call returns instead of waiting for the poll interval. """ coordinator = getattr(runtime, "coordinator", None) @@ -257,12 +72,6 @@ async def _refresh_entities(runtime: Any) -> None: await coordinator.async_refresh() -def _generation_kwargs(hass: HomeAssistant, runtime: Any) -> dict[str, Any]: - time_zone = ZoneInfo(hass.config.time_zone) - today = datetime.datetime.now(time_zone).date() - return {"today": today, "horizon_days": runtime.settings.horizon_days} - - def _actor_kwargs(call: Any) -> dict[str, Any]: actor = call.data["actor"] if actor == ACTOR_PANEL: @@ -353,289 +162,29 @@ def _complete_quest( return _with_errors(_handler) -def _uncomplete_quest( - hass: HomeAssistant, find_runtime: FindRuntime -) -> ServiceHandler: - async def _handler(call: Any) -> None: - runtime = _require_runtime(hass, find_runtime) - instance_id = call.data["instance_id"] - result = await completion_layer.uncomplete_instance( - runtime.database, - instance_id, - **_actor_kwargs(call), - ) - if result.appended: - await fire_quest_uncompleted( - hass, runtime.database, instance_id - ) - await _refresh_entities(runtime) - - return _with_errors(_handler) - - -def _create_quest_definition( - hass: HomeAssistant, find_runtime: FindRuntime -) -> ServiceHandler: - async def _handler(call: Any) -> None: - database = _require_runtime(hass, find_runtime).database - data = call.data - await quest_definitions_layer.create_quest_definition( - database, - data["title"], - ScheduleRule.from_dict(data["rule"]), - data["assignee_child_ids"], - data["windows"], - description=data.get("description"), - icon=data.get("icon"), - ) - - return _with_errors(_handler) - - -def _update_quest_definition( - hass: HomeAssistant, find_runtime: FindRuntime -) -> ServiceHandler: - async def _handler(call: Any) -> None: - runtime = _require_runtime(hass, find_runtime) - data = call.data - kwargs: dict[str, Any] = _generation_kwargs(hass, runtime) - if "title" in data: - kwargs["title"] = data["title"] - if "description" in data: - kwargs["description"] = data["description"] - if "icon" in data: - kwargs["icon"] = data["icon"] - if "rule" in data: - kwargs["rule"] = ScheduleRule.from_dict(data["rule"]) - if "windows" in data: - kwargs["windows"] = data["windows"] - await quest_definitions_layer.edit_quest_definition( - runtime.database, data["definition_id"], **kwargs - ) - - return _with_errors(_handler) - - -def _set_quest_definition_active( - hass: HomeAssistant, find_runtime: FindRuntime -) -> ServiceHandler: - async def _handler(call: Any) -> None: - runtime = _require_runtime(hass, find_runtime) - await quest_definitions_layer.set_quest_definition_active( - runtime.database, - call.data["definition_id"], - call.data["is_active"], - **_generation_kwargs(hass, runtime), - ) - - return _with_errors(_handler) - - -def _set_presence_pattern( - hass: HomeAssistant, find_runtime: FindRuntime -) -> ServiceHandler: - async def _handler(call: Any) -> None: - runtime = _require_runtime(hass, find_runtime) - data = call.data - await presence_management_layer.set_presence_schedule( - runtime.database, - data["child_id"], - data["cycle_length_weeks"], - data["anchor_date"], - data["pattern"], - **_generation_kwargs(hass, runtime), - ) - - return _with_errors(_handler) - - -def _create_presence_override( - hass: HomeAssistant, find_runtime: FindRuntime -) -> ServiceHandler: - async def _handler(call: Any) -> None: - runtime = _require_runtime(hass, find_runtime) - data = call.data - await presence_management_layer.create_presence_override( - runtime.database, - data["child_id"], - data["start_date"], - data["end_date"], - data["is_present"], - note=data.get("note"), - **_generation_kwargs(hass, runtime), - ) - - return _with_errors(_handler) - - -def _delete_presence_override( - hass: HomeAssistant, find_runtime: FindRuntime -) -> ServiceHandler: - async def _handler(call: Any) -> None: - runtime = _require_runtime(hass, find_runtime) - await presence_management_layer.delete_presence_override( - runtime.database, - call.data["override_id"], - **_generation_kwargs(hass, runtime), - ) - - return _with_errors(_handler) - - -async def _export_history_csv(_call: Any) -> None: - raise HomeAssistantError( - "CSV history export is not implemented until Feature 13" - ) - - -def _manage_child( - hass: HomeAssistant, find_runtime: FindRuntime -) -> ServiceHandler: - async def _handler(call: Any) -> None: - database = _require_runtime(hass, find_runtime).database - data = call.data - action = data["action"] - if action == MANAGE_CHILD_CREATE: - display_name = data.get("display_name") - if display_name is None: - raise HomeAssistantError( - "display_name is required when action is create" - ) - kwargs: dict[str, Any] = {} - if "colour" in data: - kwargs["colour"] = data["colour"] - if "avatar_ref" in data: - kwargs["avatar_ref"] = data["avatar_ref"] - if "sort_order" in data: - kwargs["sort_order"] = data["sort_order"] - await children_layer.create_child( - database, display_name, **kwargs - ) - return - if action == MANAGE_CHILD_EDIT: - child_id = data.get("child_id") - if child_id is None: - raise HomeAssistantError( - "child_id is required when action is edit" - ) - kwargs = {} - if "display_name" in data: - kwargs["display_name"] = data["display_name"] - if "colour" in data: - kwargs["colour"] = data["colour"] - if "avatar_ref" in data: - kwargs["avatar_ref"] = data["avatar_ref"] - if "sort_order" in data: - kwargs["sort_order"] = data["sort_order"] - await children_layer.edit_child(database, child_id, **kwargs) - return - if action == MANAGE_CHILD_SET_ACTIVE: - child_id = data.get("child_id") - is_active = data.get("is_active") - if child_id is None: - raise HomeAssistantError( - "child_id is required when action is set_active" - ) - if is_active is None: - raise HomeAssistantError( - "is_active is required when action is set_active" - ) - await children_layer.set_child_active( - database, child_id, is_active - ) - return - ordered_ids = data.get("ordered_ids") - if ordered_ids is None: - raise HomeAssistantError( - "ordered_ids is required when action is reorder" - ) - await children_layer.reorder_children(database, ordered_ids) - - return _with_errors(_handler) - - def async_register_services( hass: HomeAssistant, *, find_runtime: FindRuntime, - regenerate_handler: ServiceHandler, ) -> None: - """Register every canonical service once at domain scope. + """Register the panel completion service once at domain scope. - Every service — regenerate included — passes through the Feature - 09 permission gate before its handler runs; the gate reads - :data:`~.service_policy.SERVICE_POLICY`, so no service can be - registered exempt from the policy. + The one remaining HA service is the panel completion path; every + admin operation moved to the API service's admin routes, so there + is no permission gate to apply here. """ - if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): + if hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST): return - gate = permission_gate - specs: list[tuple[str, ServiceHandler, object]] = [ - (SERVICE_REGENERATE, regenerate_handler, None), - ( - SERVICE_COMPLETE_QUEST, - _complete_quest(hass, find_runtime), - SCHEMA_COMPLETE_QUEST, - ), - ( - SERVICE_UNCOMPLETE_QUEST, - _uncomplete_quest(hass, find_runtime), - SCHEMA_UNCOMPLETE_QUEST, - ), - ( - SERVICE_CREATE_QUEST_DEFINITION, - _create_quest_definition(hass, find_runtime), - SCHEMA_CREATE_QUEST_DEFINITION, - ), - ( - SERVICE_UPDATE_QUEST_DEFINITION, - _update_quest_definition(hass, find_runtime), - SCHEMA_UPDATE_QUEST_DEFINITION, - ), - ( - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - _set_quest_definition_active(hass, find_runtime), - SCHEMA_SET_QUEST_DEFINITION_ACTIVE, - ), - ( - SERVICE_SET_PRESENCE_PATTERN, - _set_presence_pattern(hass, find_runtime), - SCHEMA_SET_PRESENCE_PATTERN, - ), - ( - SERVICE_CREATE_PRESENCE_OVERRIDE, - _create_presence_override(hass, find_runtime), - SCHEMA_CREATE_PRESENCE_OVERRIDE, - ), - ( - SERVICE_DELETE_PRESENCE_OVERRIDE, - _delete_presence_override(hass, find_runtime), - SCHEMA_DELETE_PRESENCE_OVERRIDE, - ), - ( - SERVICE_EXPORT_HISTORY_CSV, - _export_history_csv, - SCHEMA_EXPORT_HISTORY_CSV, - ), - ( - SERVICE_MANAGE_CHILD, - _manage_child(hass, find_runtime), - SCHEMA_MANAGE_CHILD, - ), - ] - - def _database_of(_call: Any) -> Any: - return _require_runtime(hass, find_runtime).database - - for service, handler, schema in specs: - gated = gate( - service, SERVICE_POLICY[service], _database_of - )(handler) - hass.services.async_register(DOMAIN, service, gated, schema=schema) + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_QUEST, + _complete_quest(hass, find_runtime), + schema=SCHEMA_COMPLETE_QUEST, + ) def async_deregister_services(hass: HomeAssistant) -> None: """Remove every canonical service when the last entry unloads.""" for service in DOMAIN_SERVICES: if hass.services.has_service(DOMAIN, service): - hass.services.async_remove(DOMAIN, service) + hass.services.async_remove(DOMAIN, service) \ No newline at end of file diff --git a/custom_components/nestquest/services.yaml b/custom_components/nestquest/services.yaml index 892d318..982c336 100644 --- a/custom_components/nestquest/services.yaml +++ b/custom_components/nestquest/services.yaml @@ -1,15 +1,8 @@ # Service declarations for the NestQuest integration (Home Assistant # services.yaml metadata). Services are domain-scoped: nested under the -# "nestquest" domain, not repeated per config entry. - -regenerate: - name: Regenerate quest instances - description: >- - Regenerate every future quest instance for the household over the rolling - horizon, using the same idempotent materialization as the daily rollover - and the startup backfill. Safe to call at any time: a re-run refreshes the - same rows instead of duplicating them, and completed instances are never - touched. +# "nestquest" domain, not repeated per config entry. Since Feature 18 +# only the panel completion service remains — every admin operation +# moved to the PWA and the API service's admin routes. complete_quest: name: Complete quest @@ -43,284 +36,4 @@ complete_quest: description: Home Assistant user id when actor is user and no call context is present. required: false selector: - text: - -uncomplete_quest: - name: Uncomplete quest - description: Undo a quest instance completion. - fields: - instance_id: - name: Instance ID - description: The quest instance to uncomplete. - required: true - selector: - number: - mode: box - actor: - name: Actor - description: Who is undoing the completion (panel or user). - required: true - selector: - select: - options: - - panel - - user - actor_child_id: - name: Actor child ID - description: The tapped child profile. Required when actor is panel. - required: false - selector: - number: - mode: box - actor_user_id: - name: Actor user ID - description: Home Assistant user id when actor is user and no call context is present. - required: false - selector: - text: - -create_quest_definition: - name: Create quest definition - description: Create a quest definition with its schedule rule, assignees and windows. - fields: - title: - name: Title - description: Display title for the quest. - required: true - selector: - text: - rule: - name: Rule - description: Recurrence rule as a mapping (rule_type plus shape fields). - required: true - selector: - object: - assignee_child_ids: - name: Assignee child IDs - description: Child ids this definition is assigned to. - required: true - selector: - object: - windows: - name: Windows - description: Day windows (morning, afternoon, evening), optionally with a due time. - required: true - selector: - object: - description: - name: Description - description: Optional longer description. - required: false - selector: - text: - icon: - name: Icon - description: Optional icon name. - required: false - selector: - text: - -update_quest_definition: - name: Update quest definition - description: Edit a quest definition's title, description, icon, rule or windows. - fields: - definition_id: - name: Definition ID - description: The quest definition to update. - required: true - selector: - number: - mode: box - title: - name: Title - description: Replacement display title. - required: false - selector: - text: - description: - name: Description - description: Replacement description, or null to clear. - required: false - selector: - text: - icon: - name: Icon - description: Replacement icon, or null to clear. - required: false - selector: - text: - rule: - name: Rule - description: Replacement recurrence rule as a mapping. - required: false - selector: - object: - windows: - name: Windows - description: Replacement day windows. - required: false - selector: - object: - -set_quest_definition_active: - name: Set quest definition active - description: Deactivate or reactivate a quest definition. - fields: - definition_id: - name: Definition ID - description: The quest definition to update. - required: true - selector: - number: - mode: box - is_active: - name: Active - description: Whether the definition should generate future instances. - required: true - selector: - boolean: - -set_presence_pattern: - name: Set presence pattern - description: Upsert a child's repeating presence schedule. - fields: - child_id: - name: Child ID - description: The child whose schedule is being set. - required: true - selector: - number: - mode: box - cycle_length_weeks: - name: Cycle length weeks - description: Number of weeks in the repeating cycle (1 to 4). - required: true - selector: - number: - mode: box - min: 1 - max: 4 - anchor_date: - name: Anchor date - description: Cycle anchor as a YYYY-MM-DD date. - required: true - selector: - date: - pattern: - name: Pattern - description: Mapping of week index to present weekday ints (Monday is 0). - required: true - selector: - object: - -create_presence_override: - name: Create presence override - description: Create a date-range presence override for a child. - fields: - child_id: - name: Child ID - description: The child the override applies to. - required: true - selector: - number: - mode: box - start_date: - name: Start date - description: Inclusive start date (YYYY-MM-DD). - required: true - selector: - date: - end_date: - name: End date - description: Inclusive end date (YYYY-MM-DD). - required: true - selector: - date: - is_present: - name: Present - description: Whether the child is present for this range. - required: true - selector: - boolean: - note: - name: Note - description: Optional note stored with the override. - required: false - selector: - text: - -delete_presence_override: - name: Delete presence override - description: Delete a presence override by id. - fields: - override_id: - name: Override ID - description: The override to delete. - required: true - selector: - number: - mode: box - -export_history_csv: - name: Export history CSV - description: Export completion history as CSV. Not implemented until Feature 13. - -manage_child: - name: Manage child - description: Create, edit, activate or reorder child profiles. - fields: - action: - name: Action - description: create, edit, set_active, or reorder. - required: true - selector: - select: - options: - - create - - edit - - set_active - - reorder - child_id: - name: Child ID - description: Required for edit and set_active. - required: false - selector: - number: - mode: box - display_name: - name: Display name - description: Required for create; optional for edit. - required: false - selector: - text: - colour: - name: Colour - description: Optional colour string. - required: false - selector: - text: - avatar_ref: - name: Avatar - description: Optional avatar reference. - required: false - selector: - text: - sort_order: - name: Sort order - description: Optional panel sort order. - required: false - selector: - number: - mode: box - is_active: - name: Active - description: Required for set_active. - required: false - selector: - boolean: - ordered_ids: - name: Ordered IDs - description: Complete permutation of child ids. Required for reorder. - required: false - selector: - object: + text: \ No newline at end of file diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py index b843057..f135bb6 100644 --- a/tests/test_entity_matrix.py +++ b/tests/test_entity_matrix.py @@ -22,7 +22,7 @@ from custom_components.nestquest.children import ( list_children, set_child_active, ) -from custom_components.nestquest.completion import derive_state +from custom_components.nestquest.completion import derive_state, uncomplete_instance from custom_components.nestquest.const import ( CONF_ADMIN_USER_IDS, DOMAIN, @@ -30,7 +30,9 @@ from custom_components.nestquest.const import ( EVENT_QUEST_COMPLETED, EVENT_QUEST_UNCOMPLETED, SERVICE_COMPLETE_QUEST, - SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.core.events import ( + build_quest_uncompleted_events, ) from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.materialize import materialize @@ -174,14 +176,29 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: ) # --- Reversing one quest fires the uncompleted event exactly once - # and restores the owed counts. - await hass.services.call( - DOMAIN, - SERVICE_UNCOMPLETE_QUEST, - {"instance_id": instances[0].id, "actor": "user"}, - context={"user_id": "admin-1"}, + # and restores the owed counts. The HA ``uncomplete_quest`` + # service is gone (the API service's admin route is the reversal + # path): drive the core completion layer it calls, publish the + # uncompleted transition the way the API does, and deliver it + # through the SSE subscription. + reversal = await uncomplete_instance( + entry.runtime_data.database, + instances[0].id, + actor_source="user", + actor_user_id="admin-1", ) + assert reversal.appended is True + client = coordinator.api_client + client.published_frames.extend( + await build_quest_uncompleted_events( + entry.runtime_data.database, instances[0].id + ) + ) + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 + # The reversal no longer carries an immediate local refresh: the + # coordinator's next poll picks the restored counts up. + await coordinator.async_refresh() assert _sensor(hass, ada.id, "all_done").is_on is False assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 1 diff --git a/tests/test_events.py b/tests/test_events.py index 5e03a45..160bd07 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -9,7 +9,11 @@ re-fires them. The service-driven tests below therefore deliver the frames through :func:`conftest.refire_api_transitions` (the production event path, driven synchronously) before asserting the bus; the shim-level tests drive :mod:`custom_components.nestquest -.events` directly, the way its remaining callers do. +.events` directly, the way its remaining callers do. The HA +``uncomplete_quest`` service is also gone (the API service's admin +uncomplete route is the reversal path), so the reversal tests drive +the core completion layer the route calls and deliver the published +frame through the same SSE subscription helper. """ from __future__ import annotations @@ -19,6 +23,7 @@ from conftest import refire_api_transitions, wire_entry_to_registry from custom_components.nestquest import async_setup_entry from custom_components.nestquest.children import create_child +from custom_components.nestquest.completion import uncomplete_instance from custom_components.nestquest.const import ( CONF_ADMIN_USER_IDS, DOMAIN, @@ -27,10 +32,9 @@ from custom_components.nestquest.const import ( EVENT_QUEST_MISSED, EVENT_QUEST_UNCOMPLETED, SERVICE_COMPLETE_QUEST, - SERVICE_CREATE_QUEST_DEFINITION, - SERVICE_MANAGE_CHILD, - SERVICE_REGENERATE, - SERVICE_UNCOMPLETE_QUEST, +) +from custom_components.nestquest.core.events import ( + build_quest_uncompleted_events, ) from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.materialize import materialize @@ -39,7 +43,6 @@ from custom_components.nestquest.quest_definitions import ( ) from custom_components.nestquest.recurrence import ScheduleRule -ADMIN_CTX = {"user_id": "admin-1"} _TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") @@ -61,15 +64,7 @@ async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=N ) assert await async_setup_entry(hass, entry) is True database = entry.runtime_data.database - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - from custom_components.nestquest.children import list_children - - child = (await list_children(database))[0] + child = await create_child(database, "Ada") await create_quest_definition( database, "Brush teeth", @@ -205,32 +200,44 @@ async def test_child_day_complete_does_not_fire_partway( async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: + """An actual reversal fires the uncompleted event — but only once, + and only through the API service's transition stream: the HA + ``uncomplete_quest`` service is gone, so the reversal is driven + through the core completion layer the API's admin uncomplete route + calls, with the published frame delivered by the SSE subscription.""" entry, child, instances = await _setup_and_seed(hass, make_entry) instance_id = instances[0].id + database = entry.runtime_data.database + client = entry.runtime_data.coordinator.api_client await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, ) - # Un-completing an open instance is a no-op and fires nothing. - await hass.services.call( - DOMAIN, - SERVICE_UNCOMPLETE_QUEST, - {"instance_id": instance_id, "actor": "user"}, - context=ADMIN_CTX, + # The reversal appends through the core completion layer; the API + # route publishes the transition on its SSE stream and the + # subscription re-fires it on the bus. + result = await uncomplete_instance( + database, instance_id, actor_source="user", actor_user_id="admin-1" ) + assert result.appended is True + client.published_frames.extend( + await build_quest_uncompleted_events(database, instance_id) + ) + await refire_api_transitions(hass, entry, client) fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED) assert len(fired) == 1 payload = fired[0] assert payload["instance_id"] == instance_id assert payload["quest_title"] == "Brush teeth" assert _TIMESTAMP.match(payload["occurred_at"]) - await hass.services.call( - DOMAIN, - SERVICE_UNCOMPLETE_QUEST, - {"instance_id": instance_id, "actor": "user"}, - context=ADMIN_CTX, + # Un-completing an already-open instance is a no-op: nothing is + # appended, nothing is published, and nothing further fires. + no_op = await uncomplete_instance( + database, instance_id, actor_source="user", actor_user_id="admin-1" ) + assert no_op.appended is False + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 @@ -390,40 +397,6 @@ async def test_completion_updates_entities_immediately( ) -async def test_uncompletion_updates_entities_immediately( - hass, make_entry -) -> None: - entry, child, instances = await _setup_and_seed(hass, make_entry) - coordinator = entry.runtime_data.coordinator - await coordinator.async_refresh() - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instances[0].id, - "actor": "panel", - "actor_child_id": child.id, - }, - ) - completed_after_complete = hass.entities[ - f"nestquest_child_{child.id}_quests_completed_today" - ].native_value - assert completed_after_complete == 1 - - await hass.services.call( - DOMAIN, - SERVICE_UNCOMPLETE_QUEST, - {"instance_id": instances[0].id, "actor": "user"}, - context=ADMIN_CTX, - ) - completed_after_uncomplete = hass.entities[ - f"nestquest_child_{child.id}_quests_completed_today" - ].native_value - assert completed_after_uncomplete == 0, ( - "sensor must reflect the reversal without a manual refresh" - ) - - async def test_forced_refreshes_serialize_no_stale_publish( hass, make_entry ) -> None: diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 341522e..a0f39e7 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -9,22 +9,14 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, CONF_MORNING_SUMMARY_TIME, DEFAULT_HORIZON_DAYS, - SERVICE_REGENERATE, + SERVICE_COMPLETE_QUEST, DOMAIN as DOMAIN_CONST, ) -ADMIN_ID = "admin-1" -ADMIN_CTX = {"user_id": ADMIN_ID} - - -def _admin_data(): - return {CONF_ADMIN_USER_IDS: [ADMIN_ID]} - def _wire(entry, registry): return wire_entry_to_registry(entry, registry) @@ -601,127 +593,6 @@ async def test_setup_keeps_horizon_when_sibling_option_is_bad( assert entry.runtime_data.settings.horizon_days == 5 -async def test_regenerate_service_registered_and_materializes( - hass, make_entry -) -> None: - """``nestquest.regenerate`` is registered and calling it regenerates.""" - import datetime - - from custom_components.nestquest.dao_instances import QuestInstancesDao - - entry = _wire(make_entry(data=_admin_data()), hass.registry) - assert await async_setup_entry(hass, entry) is True - assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) - - # A definition created AFTER setup has no instances yet (create does not - # materialize); the service must generate them on demand. - database = entry.runtime_data.database - child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) - - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - - today = _local_today(hass) - end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() - records = await QuestInstancesDao(database).list_by_date_range( - child_id, today.isoformat(), end - ) - assert len(records) == DEFAULT_HORIZON_DAYS + 1 - - -async def test_regenerate_service_uses_configured_horizon_days( - hass, make_entry -) -> None: - """``nestquest.regenerate`` regenerates only the configured horizon.""" - import datetime - - from custom_components.nestquest.dao_instances import QuestInstancesDao - - entry = _wire( - make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()), - hass.registry, - ) - assert await async_setup_entry(hass, entry) is True - - database = entry.runtime_data.database - child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) - - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - - today = _local_today(hass) - end = (today + datetime.timedelta(days=5)).isoformat() - records = await QuestInstancesDao(database).list_by_date_range( - child_id, today.isoformat(), end - ) - assert len(records) == 6 - - beyond = (today + datetime.timedelta(days=6)).isoformat() - beyond_end = ( - today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - assert await QuestInstancesDao(database).list_by_date_range( - child_id, beyond, beyond_end - ) == [] - - -async def test_unload_deregisters_regenerate_service(hass, make_entry) -> None: - """Unload removes the ``regenerate`` service so no stale entry leaks.""" - entry = _wire(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) - - assert await async_unload_entry(hass, entry) is True - assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) - - -async def test_regenerate_service_is_domain_scoped_across_entries( - hass, make_entry -) -> None: - """The ``regenerate`` service is domain-global, not per-entry. - - With two loaded entries the service must be registered and functional; - unloading one entry must keep it registered and functional for the - remaining entry; unloading the final entry must remove it. - """ - import datetime - - from custom_components.nestquest.dao_instances import QuestInstancesDao - - entry_a = _wire( - make_entry(entry_id="entry_a", data=_admin_data()), hass.registry - ) - entry_b = _wire( - make_entry(entry_id="entry_b", data=_admin_data()), hass.registry - ) - - assert await async_setup_entry(hass, entry_a) is True - assert await async_setup_entry(hass, entry_b) is True - assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) - - # The domain-global handler resolves a live database from any entry. - database_b = entry_b.runtime_data.database - child_id = await _seed_daily_child_and_definition( - database_b, _local_today(hass) - ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - today = _local_today(hass) - end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() - records = await QuestInstancesDao(database_b).list_by_date_range( - child_id, today.isoformat(), end - ) - assert len(records) == DEFAULT_HORIZON_DAYS + 1 - - # Unloading one entry must not tear down the shared service. - assert await async_unload_entry(hass, entry_a) is True - assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) - - # It still works for the remaining entry. - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - - # Unloading the final entry removes the domain-global service. - assert await async_unload_entry(hass, entry_b) is True - assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) - - async def test_setup_cleans_listeners_when_materialization_raises( hass, make_entry, monkeypatch ) -> None: @@ -745,5 +616,5 @@ async def test_setup_cleans_listeners_when_materialization_raises( assert hass.time_change.size == 0 assert hass.registry.size == 0 - assert not hass.services.has_service(DOMAIN, SERVICE_REGENERATE) + assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST) assert not hasattr(entry, "runtime_data") \ No newline at end of file diff --git a/tests/test_permission_matrix.py b/tests/test_permission_matrix.py deleted file mode 100644 index 14e521e..0000000 --- a/tests/test_permission_matrix.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Feature 09 permission test matrix across every registered service.""" -from __future__ import annotations - -import datetime - -import pytest -from homeassistant.exceptions import Unauthorized - -from conftest import wire_entry_to_registry - -from custom_components.nestquest import async_setup_entry -from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, - DEFAULT_HORIZON_DAYS, - DOMAIN, - DOMAIN_SERVICES, - SERVICE_COMPLETE_QUEST, - SERVICE_CREATE_PRESENCE_OVERRIDE, - SERVICE_CREATE_QUEST_DEFINITION, - SERVICE_DELETE_PRESENCE_OVERRIDE, - SERVICE_EXPORT_HISTORY_CSV, - SERVICE_MANAGE_CHILD, - SERVICE_REGENERATE, - SERVICE_SET_PRESENCE_PATTERN, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - SERVICE_UNCOMPLETE_QUEST, - SERVICE_UPDATE_QUEST_DEFINITION, -) -from custom_components.nestquest.service_policy import ( - ADMIN_ONLY, - OPEN, - SERVICE_POLICY, -) - -ADMIN_ID = "admin-1" -ADMIN_CTX = {"user_id": ADMIN_ID} -NON_ADMIN_ID = "non-admin" -NON_ADMIN_CTX = {"user_id": NON_ADMIN_ID} -KIOSK_ID = "kiosk-panel" -KIOSK_CTX = {"user_id": KIOSK_ID} - - -def test_every_registered_service_is_in_the_operation_registry() -> None: - """The registry is total: a service outside it could never be gated.""" - missing = [ - service for service in DOMAIN_SERVICES if service not in SERVICE_POLICY - ] - extra = [ - service for service in SERVICE_POLICY if service not in DOMAIN_SERVICES - ] - assert not missing, f"registered but ungated: {missing}" - assert not extra, f"gated but never registered: {extra}" - assert set(SERVICE_POLICY.values()) == {OPEN, ADMIN_ONLY} - - -async def _setup(hass, make_entry, *, admin=True): - data = {CONF_ADMIN_USER_IDS: [ADMIN_ID]} if admin else {} - entry = wire_entry_to_registry(make_entry(data=data), hass.registry) - assert await async_setup_entry(hass, entry) is True - return entry - - -async def _seed(hass, entry) -> dict: - """Seed one child, one definition, and materialized instances.""" - from custom_components.nestquest.children import list_children - from custom_components.nestquest.dao_instances import QuestInstancesDao - - database = entry.runtime_data.database - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - await hass.services.call( - DOMAIN, - SERVICE_CREATE_QUEST_DEFINITION, - { - "title": "Brush teeth", - "rule": {"rule_type": "daily"}, - "assignee_child_ids": [(await list_children(database))[0].id], - "windows": ["morning"], - }, - context=ADMIN_CTX, - ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - child = (await list_children(database))[0] - today = datetime.date.today().isoformat() - end = ( - datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today, end - ) - assert instances - from custom_components.nestquest.dao_presence import ( - PresenceOverridesDao, - ) - - return { - "child_id": child.id, - "instance_id": instances[0].id, - "definition_id": instances[0].definition_id, - "override": await PresenceOverridesDao(database).create( - child.id, "2027-01-01", "2027-01-02", False - ), - "today": today, - "database": database, - } - - -def _admin_success_payload(service: str, ctx: dict) -> dict | None: - """A schema-valid, end-to-end-successful payload per admin service.""" - if service == SERVICE_UNCOMPLETE_QUEST: - return {"instance_id": ctx["instance_id"], "actor": "user"} - if service == SERVICE_CREATE_QUEST_DEFINITION: - return { - "title": "Second quest", - "rule": {"rule_type": "daily"}, - "assignee_child_ids": [ctx["child_id"]], - "windows": ["afternoon"], - } - if service == SERVICE_UPDATE_QUEST_DEFINITION: - return {"definition_id": ctx["definition_id"], "title": "Brush teeth 2"} - if service == SERVICE_SET_QUEST_DEFINITION_ACTIVE: - return {"definition_id": ctx["definition_id"], "is_active": False} - if service == SERVICE_SET_PRESENCE_PATTERN: - return { - "child_id": ctx["child_id"], - "cycle_length_weeks": 1, - "anchor_date": ctx["today"], - "pattern": {0: [0, 1, 2, 3, 4, 5, 6]}, - } - if service == SERVICE_CREATE_PRESENCE_OVERRIDE: - return { - "child_id": ctx["child_id"], - "start_date": "2027-03-01", - "end_date": "2027-03-02", - "is_present": False, - } - if service == SERVICE_DELETE_PRESENCE_OVERRIDE: - return {"override_id": ctx["override"].id} - if service == SERVICE_EXPORT_HISTORY_CSV: - return None # raises HomeAssistantError (Feature 13) — not Unauthorized - if service == SERVICE_MANAGE_CHILD: - return {"action": "create", "display_name": "Bo"} - if service == SERVICE_REGENERATE: - return {} - raise AssertionError(f"unexpected service {service}") - - -_ADMIN_ONLY = [ - service for service, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY -] - - -@pytest.mark.parametrize("service", _ADMIN_ONLY) -async def test_admin_succeeds_on_every_admin_only_service( - hass, make_entry, service -) -> None: - from homeassistant.exceptions import HomeAssistantError - - entry = await _setup(hass, make_entry) - ctx = await _seed(hass, entry) - if service == SERVICE_EXPORT_HISTORY_CSV: - # The gate passes; the Feature 13 stub raises its "not - # implemented" HomeAssistantError — NOT Unauthorized. - with pytest.raises(HomeAssistantError, match="Feature 13"): - await hass.services.call( - DOMAIN, service, None, context=ADMIN_CTX - ) - return - await hass.services.call( - DOMAIN, service, _admin_success_payload(service, ctx), context=ADMIN_CTX - ) - - -@pytest.mark.parametrize("service", _ADMIN_ONLY) -async def test_non_admin_refused_on_every_admin_only_service( - hass, make_entry, service -) -> None: - entry = await _setup(hass, make_entry) - ctx = await _seed(hass, entry) - if service != SERVICE_EXPORT_HISTORY_CSV: - await hass.services.call( - DOMAIN, - service, - _admin_success_payload(service, ctx), - context=ADMIN_CTX, - ) - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - service, - _admin_success_payload(service, ctx), - context=NON_ADMIN_CTX, - ) - - -async def test_non_admin_allowed_on_complete_quest(hass, make_entry) -> None: - from custom_components.nestquest.completion import instance_state - - entry = await _setup(hass, make_entry) - ctx = await _seed(hass, entry) - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": ctx["instance_id"], - "actor": "panel", - "actor_child_id": ctx["child_id"], - }, - context=NON_ADMIN_CTX, - ) - assert ( - await instance_state(ctx["database"], ctx["instance_id"]) == "done" - ) - - -async def test_kiosk_refused_on_uncomplete_and_regenerate( - hass, make_entry -) -> None: - entry = await _setup(hass, make_entry) - ctx = await _seed(hass, entry) - from custom_components.nestquest.admin_allowlist import list_admin_ids - - assert KIOSK_ID not in await list_admin_ids(ctx["database"]) - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - SERVICE_UNCOMPLETE_QUEST, - {"instance_id": ctx["instance_id"], "actor": "user"}, - context=KIOSK_CTX, - ) - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, SERVICE_REGENERATE, context=KIOSK_CTX - ) \ No newline at end of file diff --git a/tests/test_permissions.py b/tests/test_permissions.py deleted file mode 100644 index b71ffce..0000000 --- a/tests/test_permissions.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Tests for the Feature 09 permission gate's audit logging.""" -from __future__ import annotations - -import logging -import re - -import pytest -from homeassistant.exceptions import Unauthorized - -from conftest import wire_entry_to_registry - -from custom_components.nestquest import async_setup_entry -from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, - DOMAIN, - SERVICE_MANAGE_CHILD, -) - -ADMIN_ID = "admin-1" -ADMIN_CTX = {"user_id": ADMIN_ID} -_TIMESTAMP = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}") - - -async def _set_up(hass, make_entry): - entry = wire_entry_to_registry( - make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}), hass.registry - ) - assert await async_setup_entry(hass, entry) is True - return entry - - -async def test_denied_call_logs_warning_with_fields( - hass, make_entry, caplog -) -> None: - entry = await _set_up(hass, make_entry) - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context={"user_id": "kiosk-panel"}, - ) - denials = [ - record - for record in caplog.records - if record.levelno == logging.WARNING - and "denied" in record.getMessage() - ] - assert len(denials) == 1 - message = denials[0].getMessage() - assert SERVICE_MANAGE_CHILD in message - assert "kiosk-panel" in message - assert _TIMESTAMP.search(message), message - - -async def test_denied_no_context_logs_user_none( - hass, make_entry, caplog -) -> None: - entry = await _set_up(hass, make_entry) - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - ) - denials = [ - record - for record in caplog.records - if record.levelno == logging.WARNING - and "denied" in record.getMessage() - ] - assert len(denials) == 1 - message = denials[0].getMessage() - assert "none" in message - assert _TIMESTAMP.search(message), message - - -async def test_successful_admin_call_logs_debug_with_fields( - hass, make_entry, caplog -) -> None: - caplog.set_level(logging.DEBUG, logger="custom_components.nestquest") - entry = await _set_up(hass, make_entry) - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - allowed = [ - record - for record in caplog.records - if record.levelno == logging.DEBUG - and "allowed" in record.getMessage() - ] - assert len(allowed) == 1 - message = allowed[0].getMessage() - assert SERVICE_MANAGE_CHILD in message - assert ADMIN_ID in message - assert _TIMESTAMP.search(message), message - - -async def test_denied_log_carries_no_personal_data_beyond_user_id( - hass, make_entry, caplog -) -> None: - """The log line names the caller's user id only: the payload's - display name must not leak into the audit trail.""" - entry = await _set_up(hass, make_entry) - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context={"user_id": "kiosk-panel"}, - ) - denials = [ - record.getMessage() - for record in caplog.records - if record.levelno == logging.WARNING and "denied" in record.getMessage() - ] - assert denials, "expected a denial log line" - assert "Ada" not in denials[0] - assert "display_name" not in denials[0] \ No newline at end of file diff --git a/tests/test_service_policy.py b/tests/test_service_policy.py deleted file mode 100644 index 0afa85c..0000000 --- a/tests/test_service_policy.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Tests for the Feature 09 admin-only versus open service registry.""" -from __future__ import annotations - -from custom_components.nestquest.const import ( - DOMAIN_SERVICES, - SERVICE_COMPLETE_QUEST, -) -from custom_components.nestquest.service_policy import ( - ADMIN_ONLY, - OPEN, - SERVICE_POLICY, -) - - -def test_policy_covers_exactly_the_registered_services() -> None: - assert frozenset(SERVICE_POLICY) == frozenset(DOMAIN_SERVICES) - - -def test_only_complete_quest_is_open() -> None: - open_services = { - name for name, access in SERVICE_POLICY.items() if access == OPEN - } - assert open_services == {SERVICE_COMPLETE_QUEST} - assert all( - access == ADMIN_ONLY - for name, access in SERVICE_POLICY.items() - if name != SERVICE_COMPLETE_QUEST - ) - - -def test_policy_values_are_only_open_or_admin_only() -> None: - assert set(SERVICE_POLICY.values()) <= {OPEN, ADMIN_ONLY} diff --git a/tests/test_services.py b/tests/test_services.py index d242a3b..5ec8520 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -1,4 +1,13 @@ -"""Tests for NestQuest service declarations and domain-global handlers.""" +"""Tests for NestQuest service declarations and domain-global handlers. + +Since Feature 18 the integration is a client of the NestQuest API +service: the only HA service left is the panel completion path +(``nestquest.complete_quest``, a proxy to the API's panel complete +route). Every admin service was removed — the admin surface is the +PWA and the API service's admin routes — so these tests pin the +shrunk surface: services.yaml declares nothing else, setup registers +nothing else, and no removed admin service name may come back. +""" from __future__ import annotations import re @@ -6,27 +15,16 @@ from pathlib import Path import pytest import voluptuous as vol -from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.exceptions import HomeAssistantError from conftest import wire_entry_to_registry from custom_components.nestquest import async_setup_entry, async_unload_entry from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, DOMAIN, DOMAIN_SERVICES, SERVICE_COMPLETE_QUEST, - SERVICE_CREATE_PRESENCE_OVERRIDE, - SERVICE_CREATE_QUEST_DEFINITION, - SERVICE_DELETE_PRESENCE_OVERRIDE, - SERVICE_EXPORT_HISTORY_CSV, - SERVICE_MANAGE_CHILD, - SERVICE_REGENERATE, - SERVICE_SET_PRESENCE_PATTERN, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - SERVICE_UNCOMPLETE_QUEST, ) -from custom_components.nestquest.service_policy import ADMIN_ONLY, SERVICE_POLICY SERVICES_PATH = ( Path(__file__).parent.parent @@ -35,21 +33,27 @@ SERVICES_PATH = ( / "services.yaml" ) +#: The admin services removed in Feature 18. None of these names may +#: be registered as an HA service again — the admin surface is the PWA +#: and the API service's admin routes. +REMOVED_ADMIN_SERVICES = ( + "uncomplete_quest", + "create_quest_definition", + "update_quest_definition", + "set_quest_definition_active", + "set_presence_pattern", + "create_presence_override", + "delete_presence_override", + "export_history_csv", + "manage_child", + "regenerate", +) + def _wire(entry, registry): return wire_entry_to_registry(entry, registry) -ADMIN_ID = "admin-1" -ADMIN_CTX = {"user_id": ADMIN_ID} - - -def _make_admin_entry(make_entry): - from custom_components.nestquest.const import CONF_ADMIN_USER_IDS - - return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}) - - def _service_section(text: str, name: str) -> str: match = re.search(rf"^{name}:\s*$", text, re.MULTILINE) assert match is not None, f"services.yaml must declare {name!r}" @@ -77,42 +81,33 @@ def test_services_yaml_declares_every_canonical_service() -> None: ) -def test_services_yaml_declares_regenerate() -> None: +def test_services_yaml_no_longer_declares_removed_admin_services() -> None: + """The admin services are gone from the HA service surface.""" text = SERVICES_PATH.read_text(encoding="utf-8") - assert re.search(r"^regenerate:\s*$", text, re.MULTILINE), ( - "services.yaml must declare the 'regenerate' service" - ) + for name in REMOVED_ADMIN_SERVICES: + assert not re.search(rf"^{name}:\s*$", text, re.MULTILINE), ( + f"removed admin service {name!r} must not be declared" + ) -def test_services_yaml_regenerate_has_description_and_no_fields() -> None: - """The regenerate service is documented with a clear description and no - required fields (there is no ``fields`` block under it).""" - text = SERVICES_PATH.read_text(encoding="utf-8") - section = _service_section(text, "regenerate") - assert re.search(r"description:", section), ( - "regenerate must carry a description" - ) - assert "fields:" not in section, ( - "regenerate declares no required fields" - ) - - -def test_services_yaml_other_services_declare_fields() -> None: +def test_services_yaml_canonical_services_declare_fields() -> None: text = SERVICES_PATH.read_text(encoding="utf-8") for name in DOMAIN_SERVICES: - if name in (SERVICE_REGENERATE, SERVICE_EXPORT_HISTORY_CSV): - continue section = _service_section(text, name) assert "fields:" in section, f"{name} must declare fields" -async def test_all_canonical_services_registered_after_setup( +async def test_only_complete_quest_registered_after_setup( hass, make_entry ) -> None: + """Setup registers exactly the panel completion service — and no + removed admin service under any name.""" entry = _wire(make_entry(), hass.registry) assert await async_setup_entry(hass, entry) is True for service in DOMAIN_SERVICES: assert hass.services.has_service(DOMAIN, service), service + for service in REMOVED_ADMIN_SERVICES: + assert not hass.services.has_service(DOMAIN, service), service async def test_unload_last_entry_removes_all_services(hass, make_entry) -> None: @@ -138,271 +133,6 @@ async def test_unload_one_of_two_entries_keeps_services( assert not hass.services.has_service(DOMAIN, service), service -async def test_manage_child_create_persists_child(hass, make_entry) -> None: - from custom_components.nestquest.children import list_children - - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - children = await list_children(entry.runtime_data.database) - assert [child.display_name for child in children] == ["Ada"] - - -async def test_create_and_set_quest_definition_active( - hass, make_entry -) -> None: - from custom_components.nestquest.children import list_children - from custom_components.nestquest.quest_definitions import ( - list_active_definitions, - ) - from custom_components.nestquest.dao_rules import QuestDefinitionsDao - - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - child = (await list_children(entry.runtime_data.database))[0] - await hass.services.call( - DOMAIN, - SERVICE_CREATE_QUEST_DEFINITION, - { - "title": "Brush teeth", - "rule": {"rule_type": "daily"}, - "assignee_child_ids": [child.id], - "windows": ["morning"], - }, - context=ADMIN_CTX, - ) - active = await list_active_definitions(entry.runtime_data.database) - assert len(active) == 1 - definition_id = active[0].definition.id - await hass.services.call( - DOMAIN, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - {"definition_id": definition_id, "is_active": False}, - context=ADMIN_CTX, - ) - assert await list_active_definitions(entry.runtime_data.database) == [] - stored = await QuestDefinitionsDao(entry.runtime_data.database).get( - definition_id - ) - assert stored is not None - assert stored.is_active is False - - -async def test_complete_and_uncomplete_quest_call_business_layer( - hass, make_entry -) -> None: - import datetime - - from custom_components.nestquest.children import list_children - from custom_components.nestquest.completion import instance_state - from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS - from custom_components.nestquest.dao_instances import QuestInstancesDao - - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - child = (await list_children(entry.runtime_data.database))[0] - await hass.services.call( - DOMAIN, - SERVICE_CREATE_QUEST_DEFINITION, - { - "title": "Brush teeth", - "rule": {"rule_type": "daily"}, - "assignee_child_ids": [child.id], - "windows": ["morning"], - }, - context=ADMIN_CTX, - ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - today = datetime.date.today().isoformat() - end = ( - datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - records = await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range(child.id, today, end) - assert records - instance_id = records[0].id - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance_id, - "actor": "panel", - "actor_child_id": child.id, - }, - ) - assert ( - await instance_state(entry.runtime_data.database, instance_id) - == "done" - ) - await hass.services.call( - DOMAIN, - SERVICE_UNCOMPLETE_QUEST, - { - "instance_id": instance_id, - "actor": "user", - "actor_user_id": ADMIN_ID, - }, - context=ADMIN_CTX, - ) - assert ( - await instance_state(entry.runtime_data.database, instance_id) - == "open" - ) - - -async def test_set_presence_pattern_regenerates_child_instances( - hass, make_entry -) -> None: - import datetime - - from custom_components.nestquest.children import list_children - from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS - from custom_components.nestquest.dao_instances import QuestInstancesDao - - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - child = (await list_children(entry.runtime_data.database))[0] - await hass.services.call( - DOMAIN, - SERVICE_CREATE_QUEST_DEFINITION, - { - "title": "Brush teeth", - "rule": {"rule_type": "daily"}, - "assignee_child_ids": [child.id], - "windows": ["morning"], - }, - context=ADMIN_CTX, - ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - today = datetime.date.today().isoformat() - end = ( - datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - dao = QuestInstancesDao(entry.runtime_data.database) - assert await dao.list_by_date_range(child.id, today, end) - await hass.services.call( - DOMAIN, - SERVICE_SET_PRESENCE_PATTERN, - { - "child_id": child.id, - "cycle_length_weeks": 1, - "anchor_date": today, - "pattern": {0: []}, - }, - context=ADMIN_CTX, - ) - assert await dao.list_by_date_range(child.id, today, end) == [] - - -async def test_presence_override_create_and_delete_regenerate( - hass, make_entry -) -> None: - import datetime - - from custom_components.nestquest.children import list_children - from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS - from custom_components.nestquest.dao_instances import QuestInstancesDao - from custom_components.nestquest.dao_presence import PresenceOverridesDao - - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - child = (await list_children(entry.runtime_data.database))[0] - await hass.services.call( - DOMAIN, - SERVICE_CREATE_QUEST_DEFINITION, - { - "title": "Brush teeth", - "rule": {"rule_type": "daily"}, - "assignee_child_ids": [child.id], - "windows": ["morning"], - }, - context=ADMIN_CTX, - ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - today = datetime.date.today() - end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - dao = QuestInstancesDao(entry.runtime_data.database) - before = await dao.list_by_date_range( - child.id, today.isoformat(), end.isoformat() - ) - assert before - await hass.services.call( - DOMAIN, - SERVICE_CREATE_PRESENCE_OVERRIDE, - { - "child_id": child.id, - "start_date": today.isoformat(), - "end_date": end.isoformat(), - "is_present": False, - }, - context=ADMIN_CTX, - ) - assert ( - await dao.list_by_date_range( - child.id, today.isoformat(), end.isoformat() - ) - == [] - ) - override = ( - await PresenceOverridesDao( - entry.runtime_data.database - ).list_by_child_and_range( - child.id, today.isoformat(), end.isoformat() - ) - )[0] - await hass.services.call( - DOMAIN, - SERVICE_DELETE_PRESENCE_OVERRIDE, - {"override_id": override.id}, - context=ADMIN_CTX, - ) - after = await dao.list_by_date_range( - child.id, today.isoformat(), end.isoformat() - ) - assert len(after) == len(before) - - -async def test_export_history_csv_raises_home_assistant_error( - hass, make_entry -) -> None: - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - with pytest.raises(HomeAssistantError, match="Feature 13"): - await hass.services.call( - DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX - ) - - async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None: entry = _wire(make_entry(), hass.registry) assert await async_setup_entry(hass, entry) is True @@ -414,168 +144,6 @@ async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None: SERVICE_COMPLETE_QUEST, {"instance_id": True, "actor": "panel", "actor_child_id": 1}, ) - with pytest.raises((vol.Invalid, ValueError, HomeAssistantError)): - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create"}, - ) - - -async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None: - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context={"user_id": "kiosk-panel"}, - ) - - -async def test_admin_only_service_denies_missing_user_context( - hass, make_entry -) -> None: - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - ) - - -async def test_admin_only_service_denies_admin_on_empty_allowlist( - hass, make_entry -) -> None: - """Even the allowlisted id is denied when the allowlist is empty.""" - entry = _wire(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - - -async def test_unauthorized_is_raised_before_handler_runs( - hass, make_entry -) -> None: - from custom_components.nestquest.children import list_children - - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context={"user_id": "kiosk-panel"}, - ) - assert await list_children(entry.runtime_data.database) == [] - - -_MINIMAL_PAYLOADS = { - "uncomplete_quest": {"instance_id": 1, "actor": "user"}, - "create_quest_definition": { - "title": "T", - "rule": {"rule_type": "daily"}, - "assignee_child_ids": [1], - "windows": ["morning"], - }, - "update_quest_definition": {"definition_id": 1}, - "set_quest_definition_active": {"definition_id": 1, "is_active": True}, - "set_presence_pattern": { - "child_id": 1, - "cycle_length_weeks": 1, - "anchor_date": "2026-09-19", - "pattern": {0: [0]}, - }, - "create_presence_override": { - "child_id": 1, - "start_date": "2026-09-19", - "end_date": "2026-09-19", - "is_present": False, - }, - "delete_presence_override": {"override_id": 1}, - "export_history_csv": {}, - "manage_child": {"action": "create", "display_name": "Ada"}, - "regenerate": {}, -} - -_ADMIN_ONLY_SERVICES = [ - name for name, policy in SERVICE_POLICY.items() if policy == ADMIN_ONLY -] - - -@pytest.mark.parametrize("service", _ADMIN_ONLY_SERVICES) -async def test_admin_only_service_fails_closed_without_user_context( - hass, make_entry, service -) -> None: - """A call with no user_id is denied on every admin-only service.""" - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - with pytest.raises(Unauthorized): - await hass.services.call( - DOMAIN, service, dict(_MINIMAL_PAYLOADS[service]) - ) - - -async def test_complete_quest_proceeds_without_user_context( - hass, make_entry -) -> None: - """The open service needs no user context: the kiosk panel taps a - child profile and the call goes through with no HA user at all.""" - import datetime - - from custom_components.nestquest.children import list_children - from custom_components.nestquest.completion import instance_state - from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS - from custom_components.nestquest.dao_instances import QuestInstancesDao - - entry = _wire(_make_admin_entry(make_entry), hass.registry) - assert await async_setup_entry(hass, entry) is True - await hass.services.call( - DOMAIN, - SERVICE_MANAGE_CHILD, - {"action": "create", "display_name": "Ada"}, - context=ADMIN_CTX, - ) - child = (await list_children(entry.runtime_data.database))[0] - await hass.services.call( - DOMAIN, - SERVICE_CREATE_QUEST_DEFINITION, - { - "title": "Brush teeth", - "rule": {"rule_type": "daily"}, - "assignee_child_ids": [child.id], - "windows": ["morning"], - }, - context=ADMIN_CTX, - ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) - today = datetime.date.today().isoformat() - end = ( - datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - instance_id = ( - await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range(child.id, today, end) - )[0].id - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, - ) - assert ( - await instance_state(entry.runtime_data.database, instance_id) - == "done" - ) async def test_scheduled_internals_never_call_the_service_layer( @@ -590,4 +158,4 @@ async def test_scheduled_internals_never_call_the_service_layer( assert "hass.services.call" not in source, ( "scheduled internals must call the materialization directly, " "never through hass.services.call" - ) + ) \ No newline at end of file -- 2.55.0 From a3f2adcf43e0993942c2efb2cf2c0b142b5bf82e Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 09:11:29 -0400 Subject: [PATCH 7/7] Remove the integration's database, migrations, DAOs and business modules (#172) --- custom_components/nestquest/__init__.py | 523 ++-------- .../nestquest/admin_allowlist.py | 11 - custom_components/nestquest/children.py | 11 - custom_components/nestquest/completion.py | 11 - custom_components/nestquest/config_flow.py | 22 +- custom_components/nestquest/dao_children.py | 12 - custom_components/nestquest/dao_instances.py | 12 - custom_components/nestquest/dao_meta.py | 11 - custom_components/nestquest/dao_presence.py | 11 - custom_components/nestquest/dao_rules.py | 17 - custom_components/nestquest/db.py | 31 - custom_components/nestquest/events.py | 81 -- custom_components/nestquest/materialize.py | 11 - custom_components/nestquest/migrations.py | 11 - custom_components/nestquest/options_flow.py | 374 ++----- custom_components/nestquest/presence.py | 11 - .../nestquest/presence_management.py | 10 - .../nestquest/quest_definitions.py | 11 - custom_components/nestquest/recurrence.py | 10 - custom_components/nestquest/schema.py | 11 - custom_components/nestquest/services.py | 2 +- custom_components/nestquest/store.py | 32 - custom_components/nestquest/strings.json | 12 +- custom_components/nestquest/sweep.py | 72 -- .../nestquest/translations/en.json | 12 +- tests/conftest.py | 261 ++--- tests/test_admin_allowlist.py | 217 +---- tests/test_binary_sensors.py | 251 ++--- tests/test_children_registry.py | 16 +- tests/test_complete_quest_proxy.py | 163 +--- tests/test_completion.py | 14 +- tests/test_config_flow.py | 20 +- tests/test_core_settings.py | 91 +- tests/test_core_snapshot.py | 47 +- tests/test_dao_children.py | 37 +- tests/test_dao_instances.py | 10 +- tests/test_dao_matrix.py | 12 +- tests/test_dao_presence.py | 16 +- tests/test_dao_rules.py | 12 +- tests/test_db.py | 67 +- tests/test_entity_lifecycle.py | 227 +++-- tests/test_entity_matrix.py | 366 ++++--- tests/test_events.py | 915 ++++-------------- tests/test_lifecycle.py | 437 +-------- tests/test_materialize.py | 32 +- tests/test_migrations.py | 138 +-- tests/test_options_flow.py | 550 +---------- tests/test_presence.py | 2 +- tests/test_quest_definitions.py | 22 +- tests/test_recurrence_bounds.py | 2 +- tests/test_recurrence_consolidated.py | 4 +- tests/test_recurrence_daily.py | 4 +- tests/test_recurrence_model.py | 8 +- tests/test_recurrence_monthly_day.py | 2 +- tests/test_recurrence_monthly_weekday.py | 2 +- tests/test_recurrence_property.py | 2 +- tests/test_recurrence_weekly.py | 2 +- tests/test_recurrence_yearly.py | 2 +- tests/test_schema.py | 4 +- tests/test_sensors.py | 441 +++++---- tests/test_startup_db.py | 472 --------- tests/test_store.py | 215 ---- tests/test_sweep.py | 259 ----- tests/test_timezones.py | 355 ------- 64 files changed, 1394 insertions(+), 5635 deletions(-) delete mode 100644 custom_components/nestquest/admin_allowlist.py delete mode 100644 custom_components/nestquest/children.py delete mode 100644 custom_components/nestquest/completion.py delete mode 100644 custom_components/nestquest/dao_children.py delete mode 100644 custom_components/nestquest/dao_instances.py delete mode 100644 custom_components/nestquest/dao_meta.py delete mode 100644 custom_components/nestquest/dao_presence.py delete mode 100644 custom_components/nestquest/dao_rules.py delete mode 100644 custom_components/nestquest/db.py delete mode 100644 custom_components/nestquest/events.py delete mode 100644 custom_components/nestquest/materialize.py delete mode 100644 custom_components/nestquest/migrations.py delete mode 100644 custom_components/nestquest/presence.py delete mode 100644 custom_components/nestquest/presence_management.py delete mode 100644 custom_components/nestquest/quest_definitions.py delete mode 100644 custom_components/nestquest/recurrence.py delete mode 100644 custom_components/nestquest/schema.py delete mode 100644 custom_components/nestquest/store.py delete mode 100644 custom_components/nestquest/sweep.py delete mode 100644 tests/test_startup_db.py delete mode 100644 tests/test_store.py delete mode 100644 tests/test_sweep.py delete mode 100644 tests/test_timezones.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index 09da9ef..76493ca 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -1,23 +1,25 @@ -"""The NestQuest integration.""" +"""The NestQuest integration (Feature 18: a thin client of the NestQuest API service). + +The integration owns NO database: the API service (Feature 16) owns the +SQLite file, the migrations, and every business rule. Setup builds the +entry's ONE shared API client, the coordinator that polls the panel +snapshot through it, and the SSE subscription that re-fires the API's +transition frames on the HA bus, registers the single +``nestquest.complete_quest`` proxy service, and forwards the sensor and +binary_sensor platforms. +""" from __future__ import annotations -import asyncio -import datetime import inspect -import sqlite3 from collections.abc import Callable from dataclasses import dataclass -from pathlib import Path from typing import Any -from zoneinfo import ZoneInfo from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.event import async_track_time_change from .const import ( - CONF_ADMIN_USER_IDS, CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_SNAPSHOT_STALENESS, @@ -26,315 +28,34 @@ from .const import ( LOGGER, PLATFORMS, ) -from .core.settings import NestQuestSettings -from .frontend import async_register_frontend 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 +from .frontend import async_register_frontend from .services import async_deregister_services, async_register_services from .sse import NestQuestEventStream -from .store import async_get_db_path -from .admin_allowlist import seed_setup_admin -from .sweep import run_missed_sweep _LOGGER = LOGGER -#: SQLite reports "file is not a database" for corruption in every -#: sqlite3 version this integration targets; the version-specific -#: "encrypted or malformed" spelling (3.41+) maps to the same failure. -_CORRUPTION_MARKERS = ( - "file is not a database", - "database disk image is malformed", - "encrypted or malformed", - "is not a database", -) - -#: Primary sqlite error codes that mean "the file is corrupt" (SQLite -#: result codes, https://www.sqlite.org/c3ref/c_abort.html). Extended -#: codes carry the primary code in their low byte, so the match is on -#: errcode & 0xff. -_CORRUPT_PRIMARY_CODES = frozenset({11, 26}) # SQLITE_CORRUPT, SQLITE_NOTADB - - -def _is_corruption_error(error: BaseException) -> bool: - """Return True when ``error`` indicates a corrupt database file. - - Classification order: sqlite_errorcode (Python 3.11+) is primary - and locale-independent — SQLITE_CORRUPT and SQLITE_NOTADB, matched - on the primary byte so extended codes (SQLITE_CORRUPT_VTAB etc.) - classify correctly too. Message matching is the fallback for - older Pythons: the corruption spellings are the documented English - texts; a non-English locale could evade them, but the code check - already covers every locale on supported Pythons. - """ - if not isinstance(error, sqlite3.DatabaseError): - return False - errorcode = getattr(error, "sqlite_errorcode", None) - if errorcode is not None and (errorcode & 0xFF) in _CORRUPT_PRIMARY_CODES: - return True - message = str(error).lower() - return any(marker in message for marker in _CORRUPTION_MARKERS) - - -async def _preflight_existing_file( - hass: HomeAssistant, db_path: Path -) -> None: - """Detect corruption in an EXISTING file before it is opened read-write. - - Opening read-write persists WAL mode — for a rollback-journal file - that rewrites the header byte 18/19 and can create -wal/-shm - sidecars — which would modify a corrupt file before detection and - break the leave-untouched guarantee. This preflight therefore - validates the WHOLE file with a READ-ONLY connection (mode=ro + - immutable=1 URI, PRAGMA integrity_check, run on the executor like - every other DB call): corruption anywhere fails there, before - anything is written. ``immutable=1`` is SQLite's documented - guarantee that the connection performs NO writes and creates NO - sidecars — even a WAL-header file gets its -wal/-shm reads served - without creating the shared-memory file. Missing/empty files skip - the preflight (they are created fresh by the real open). All - filesystem stat/URI work runs inside the executor job with the - probe so the event loop never touches disk. - """ - def _probe() -> None: - # The file-state check and URI construction live inside the - # executor job: exists/stat/resolve are disk I/O. - if not db_path.exists(): - return - # A zero-byte file is neither missing nor a valid database: it - # is a corrupt (truncated-to-nothing) existing file, likely an - # interrupted first write. The leave-untouched contract applies - # to it too: refuse rather than silently initialize over it. - if db_path.stat().st_size == 0: - raise sqlite3.DatabaseError( - "existing database file is empty (zero bytes): file is " - "not a database" - ) - # Properly escape the path: '#'/'?'/'%' in a directory or file - # name would otherwise be parsed as URI fragments/parameters/ - # escapes. immutable=1: SQLite guarantees no writes and no - # sidecar creation from this connection. - uri = db_path.resolve().as_uri() + "?mode=ro&immutable=1" - conn = sqlite3.connect(uri, uri=True) - try: - row = conn.execute("PRAGMA integrity_check").fetchone() - finally: - conn.close() - verdict = row[0] if row else "missing" - if verdict != "ok": - # A non-ok integrity verdict IS corruption: raise with the - # verdict text (which matches the corruption markers) and - # the NOTADB code so classification is code-based. - raise sqlite3.DatabaseError( - f"integrity_check verdict {verdict!r} on read-only " - f"probe: file is not a database" - ) - - try: - # Every DB-touching call goes through the executor (the mock - # harness's async_add_executor_job is awaitable too). - await hass.async_add_executor_job(_probe) - except sqlite3.DatabaseError as err: - # Non-ok integrity verdicts are corruption, full stop: there is - # no benign reading of a failed whole-file integrity check, so - # ANY DatabaseError out of the probe that carries a corruption - # marker or code propagates as corruption. Only exotic probe - # failures that classify as non-corruption (e.g. a transient - # 'database is locked' on the read) are downgraded to a debug - # log, letting the real open path surface them. - if _is_corruption_error(err): - raise - raise sqlite3.DatabaseError( - f"read-only corruption probe of {db_path} failed: {err}" - ) from err - -async def _async_open_database( - hass: HomeAssistant, db_path: Path -) -> NestQuestDatabase: - """Open the NestQuest database, mapping failure modes for setup. - - Three startup cases (Feature 02 done-condition): - - - file missing: SQLite creates it fresh on open — no special path, - migrations stamp version 1 on the next step. - - file present and valid: opens normally. - - file present but corrupt: log an ERROR with the path, close - anything opened, and raise ConfigEntryNotReady so HA retries — - the file is left byte-for-byte untouched so the owner can - restore a backup. No code path here deletes or overwrites an - existing database file. - - Corruption can surface at EITHER stage: a truncated header fails - in the read-only preflight (before the file is ever opened - read-write), while damaged later pages fail during migrations — - the preflight guarantees no WAL sidecar or header write happens - on a corrupt file before it is detected. Both are classified the - same way. - """ - try: - await _preflight_existing_file(hass, db_path) - database = await make_database(hass).open(db_path) - except asyncio.CancelledError: - raise - except sqlite3.DatabaseError as err: - if _is_corruption_error(err): - _LOGGER.error( - "NestQuest database at %s is corrupt: %s. Refusing to " - "start with a corrupt database; the file was left " - "untouched so a backup can be restored. Home Assistant " - "will retry setup.", - db_path, - err, - ) - raise ConfigEntryNotReady( - f"NestQuest database corrupt: {db_path}" - ) from err - # open() failed for a non-corruption reason (locked file, - # permission problem): retryable, but nothing was opened. - _LOGGER.error( - "NestQuest database at %s failed to open: %s. Home " - "Assistant will retry setup.", - db_path, - err, - ) - raise ConfigEntryNotReady( - f"NestQuest database unavailable: {db_path}" - ) from err - try: - await apply_migrations(database) - except (sqlite3.DatabaseError, RuntimeError) as err: - await database.close() - if _is_corruption_error(err): - _LOGGER.error( - "NestQuest database at %s is corrupt: %s. Refusing to " - "start with a corrupt database; the file was left " - "untouched so a backup can be restored. Home Assistant " - "will retry setup.", - db_path, - err, - ) - raise ConfigEntryNotReady( - f"NestQuest database corrupt: {db_path}" - ) from err - # A non-corruption failure (e.g. a locked file, a future schema - # version) is also a retryable setup problem, but without the - # corruption framing. - _LOGGER.error( - "NestQuest database at %s failed to open: %s. Home " - "Assistant will retry setup.", - db_path, - err, - ) - raise ConfigEntryNotReady( - f"NestQuest database unavailable: {db_path}" - ) from err - except asyncio.CancelledError: - await database.close() - raise - return database - @dataclass class NestQuestRuntimeData: """Runtime data stored on a NestQuest config entry.""" entry_id: str - settings: NestQuestSettings - database: NestQuestDatabase remove_update_listener: Callable[[], Any] - remove_time_change_listener: Callable[[], Any] coordinator: Any = None event_stream: NestQuestEventStream | None = None -async def _async_owner_user_ids(hass: HomeAssistant) -> list[str]: - """Return every Home Assistant owner-account user id. - - The last-resort admin seed: when no persisted allowlist copy and no - flow-context user survive to first setup, the HA owner accounts are - seeded so the household is never left with zero admins (an empty - allowlist fails closed, which would lock the household out of its - own integration). Owner accounts can already do everything in HA, - so granting NestQuest admin is not a privilege escalation. - """ - auth = getattr(hass, "auth", None) - if auth is None: - return [] - users = await auth.async_get_users() - owners = [ - user.id - for user in users - if getattr(user, "is_owner", False) and getattr(user, "id", None) - ] - return owners - - -async def _run_horizon_materialization( - hass: HomeAssistant, - database: NestQuestDatabase, - settings: NestQuestSettings, -) -> None: - """Materialize the rolling horizon ``[today, today + horizon_days]``. - - "today" is computed in HA local time (``hass.config.time_zone``) rather - than the system clock, so the horizon tracks the household's own day. - The walk itself runs through :func:`~.materialize.materialize`, whose DB - access is executor-only. - - This is the ONE generation path inside the integration: the - startup backfill and the daily rollover listener both funnel - through it, so there is no duplicated materialization logic. - ``settings`` (REQUIRED — there is no - silent default; every caller already passes the entry's validated - :class:`NestQuestSettings`) carries the configured ``horizon_days``. The - window itself comes from :meth:`settings.horizon_window` so the helper is - real and shared (not recomputed inline here). - """ - time_zone = ZoneInfo(hass.config.time_zone) - today = datetime.datetime.now(time_zone).date() - start, end = settings.horizon_window(today) - await _materialize_run(database, start.isoformat(), end.isoformat(), today=today) - - -def _register_day_rollover_listener( - hass: HomeAssistant, - database: NestQuestDatabase, - settings: NestQuestSettings, -) -> Callable[[], Any]: - """Register the daily rollover listener and return its remover. - - Fires once per day at the configured local ``day_rollover_time``: - first re-materializes the rolling horizon - ``[today, today + horizon_days]`` through the shared - :func:`_run_horizon_materialization` path (the entry's configured, - validated ``horizon_days`` carried on ``settings``), then runs the - Feature 11 missed sweep — announcing yesterday's still-open quests - as missed AFTER today's instances exist, watermark-guarded so a - re-fire of the listener the same night announces nothing twice. - """ - hour, minute = settings.day_rollover_hour_minute - - async def _run_materialization(_now: datetime.datetime) -> None: - await _run_horizon_materialization(hass, database, settings) - await run_missed_sweep(hass, database) - - return async_track_time_change( - hass, _run_materialization, hour=hour, minute=minute - ) - - def _find_live_runtime_data(hass: HomeAssistant) -> NestQuestRuntimeData | None: - """Return a live runtime record with a connected database, or None. + """Return a live runtime record with a coordinator, or None. The domain-global service (the panel completion proxy) has no database of its own; it resolves whichever config entry's runtime data is currently live at call time, so it cannot hold a stale - handle to a closed connection after another entry unloads. + handle to an unloaded entry's coordinator. """ for runtime_data in hass.data.get(DOMAIN, {}).values(): - database = getattr(runtime_data, "database", None) - if database is not None and database.connected: + if getattr(runtime_data, "coordinator", None) is not None: return runtime_data return None @@ -367,47 +88,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NestQuest from a config entry.""" await async_register_frontend(hass) hass.data.setdefault(DOMAIN, {}) - existing = hass.data[DOMAIN].get(entry.entry_id) - if existing is not None: - db = getattr(existing, "database", None) - if db is None or not db.connected: - # Reopen through the SAME safety path as first setup: a - # record without a live connection (a previous setup's - # close-on-failure, or a hand-replaced file) must get the - # missing/valid/corrupt classification too, never a raw - # sqlite3 error and never skipped migrations — and the - # same allowlist seeding, so a reopened fresh file is - # never ownerless. - db = await _async_open_database( - hass, await async_get_db_path(hass) - ) - try: - stored_admin_ids = ( - entry.options.get(CONF_ADMIN_USER_IDS) - or entry.data.get(CONF_ADMIN_USER_IDS) - ) - context = getattr(entry, "context", None) - context_user_id = ( - context.get("user_id") - if isinstance(context, dict) - else None - ) - await seed_setup_admin( - db, - stored_admin_ids=stored_admin_ids, - context_user_id=context_user_id, - owner_ids=await _async_owner_user_ids(hass), - ) - except BaseException: - # A seeding failure must not leak the just-opened - # connection: close it before the error propagates, so - # HA's setup retry starts from a clean handle (the - # first-setup path closes symmetrically). - await db.close() - raise - existing.database = db - entry.runtime_data = existing - return True async def _async_update_listener( listener_hass: HomeAssistant, listener_entry: ConfigEntry @@ -415,80 +95,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Reload the entry when its options change.""" await listener_hass.config_entries.async_reload(listener_entry.entry_id) - database = await _async_open_database(hass, await async_get_db_path(hass)) remove_update_listener: Callable[[], Any] | None = None - remove_time_change_listener: Callable[[], Any] | None = None coordinator: Any = None event_stream: NestQuestEventStream | None = None try: - # Seed the admin allowlist on first setup so the owner is never - # locked out: an empty database takes the persisted admin copy - # — the options flow's saved list first (it is the current - # one), else the config flow's user from entry data — and only - # then the HA user still present on the entry context. No-op - # on a non-empty list (fail-closed narrowing is deliberate). - stored_admin_ids = ( - entry.options.get(CONF_ADMIN_USER_IDS) - or entry.data.get(CONF_ADMIN_USER_IDS) - ) - context = getattr(entry, "context", None) - context_user_id = ( - context.get("user_id") - if isinstance(context, dict) - else None - ) - owner_ids = await _async_owner_user_ids(hass) - await seed_setup_admin( - database, - stored_admin_ids=stored_admin_ids, - context_user_id=context_user_id, - owner_ids=owner_ids, - ) - # Build the explicit settings object from entry.options ONCE: the - # horizon materialization and the daily-rollover listener both - # consume it, so core never reads HA config itself. A malformed - # stored option falls back PER FIELD (via from_options_resilient) - # so a single bad sibling option cannot reset the user's - # horizon_days or day_rollover_time — the two fields core - # consumes — to their defaults. - settings = NestQuestSettings.from_options_resilient(entry.options) remove_update_listener = entry.add_update_listener(_async_update_listener) - remove_time_change_listener = _register_day_rollover_listener( - hass, database, settings - ) - # Backfill any days the daily listener missed while the integration - # was off (or freshly installed): run the one idempotent walk over - # [today, today + horizon_days] once at setup, using the entry's - # configured horizon. This runs through the SAME path as the - # daily listener. - await _run_horizon_materialization(hass, database, settings) - # The missed sweep runs at startup too (Feature 11): if HA was - # down at the rollover time, the previous night's still-open - # quests are announced as missed now instead of waiting for - # the next midnight. Watermark-guarded, so this is a no-op - # when the night's sweep already ran. - await run_missed_sweep(hass, database) _register_services(hass) - # The shared Feature 10 coordinator: one refresh cycle every - # entity reads from (CONF_UPDATE_INTERVAL seconds, default - # 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. # ONE API client for the entry, shared by the coordinator's - # snapshot polling and the SSE subscription below (the client - # is stateless — one session, one connection pool). + # snapshot polling, the SSE subscription, and the completion + # proxy (the client is stateless — one session, one connection + # pool). An entry with no panel token yet gets the + # typed-error stub (unavailable entities, never a setup crash). api_client = coordinator_client_from_entry(hass, entry) - # The shared Feature 10 coordinator: one refresh cycle every - # entity reads from (CONF_UPDATE_INTERVAL seconds, default - # 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. + # The shared coordinator: one refresh cycle every entity reads + # from (CONF_UPDATE_INTERVAL seconds, default five minutes). + # It polls the API service's panel snapshot; a failed poll + # keeps the entities rendering the last-good snapshot within + # CONF_SNAPSHOT_STALENESS, unavailable past it. coordinator = NestQuestCoordinator( hass, entry_id=entry.entry_id, @@ -513,20 +136,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: event_stream.start() runtime_data = NestQuestRuntimeData( entry_id=entry.entry_id, - settings=settings, - database=database, remove_update_listener=remove_update_listener, - remove_time_change_listener=remove_time_change_listener, coordinator=coordinator, event_stream=event_stream, ) # 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. + # the service proxy resolves the entry's runtime state through + # hass.data, and it must find the live coordinator 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 @@ -546,35 +165,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 - # listeners already registered against that (now-closed) database: - # unwind EVERY remover we acquired before the failure, keeping the - # original error as the one raised. Removal is best-effort — a - # failing remover must not mask the setup error that triggered it. - # The SSE subscription (started just before the runtime record) - # is stopped first: like the coordinator it must never outlive - # the database being closed below. + # A failure anywhere after the listener was registered must not + # leak it (or the SSE task): unwind EVERYTHING acquired before + # the failure, keeping the original error as the one raised. + # Removal is best-effort — a failing step must not mask the + # setup error that triggered it. if event_stream is not None: try: await event_stream.stop() except BaseException: pass - for remove_listener in ( - remove_time_change_listener, - remove_update_listener, - ): - if remove_listener is None: - continue + if remove_update_listener is not None: try: - result = remove_listener() - if inspect.isawaitable(result): - await result + remove_update_listener() except BaseException: pass # A coordinator created before the failure holds entity - # listeners pointed at the database being closed below — - # shut it down FIRST so no scheduled refresh can ever run - # against a closed connection. + # listeners pointed at the never-registered runtime — shut it + # down so no scheduled refresh outlives the failed setup. if coordinator is not None: try: await coordinator.async_shutdown() @@ -582,7 +190,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pass # A platform-forward failure reached this unwind AFTER # runtime_data was assigned: clear it so no later unload path - # closes the already-closed database through the stale record. + # re-invokes the already-consumed update-listener remover + # 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 @@ -591,10 +200,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # (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 - 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. @@ -612,17 +218,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if runtime_data is None: runtime_data = getattr(entry, "runtime_data", None) # Platforms unload FIRST (mirroring HA conventions): entity - # listeners must not outlive the database and coordinator the - # teardown below closes. + # listeners must not outlive the coordinator the teardown below + # shuts down. platforms_unloaded = await hass.config_entries.async_unload_platforms( entry, PLATFORMS ) if platforms_unloaded is False and runtime_data is not None: # Home Assistant KEPT one or more platforms loaded — their - # entities still live and must keep their database. Restore + # entities still live and must keep their coordinator. Restore # the runtime record popped above and report failure so HA # retries the unload later instead of leaving live entities - # backed by a closed database. + # backed by a torn-down coordinator. hass.data.setdefault(DOMAIN, {})[entry.entry_id] = runtime_data return False unload_error: BaseException | None = None @@ -634,43 +240,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: stop_stream = getattr(event_stream, "stop", None) if stop_stream is not None: await stop_stream() - remove_update_listener = getattr(runtime_data, "remove_update_listener", None) - remove_time_change_listener = getattr( - runtime_data, "remove_time_change_listener", None + remove_update_listener = getattr( + runtime_data, "remove_update_listener", None ) try: - for remove_listener in ( - remove_update_listener, - remove_time_change_listener, - ): - if remove_listener is None: - continue - try: - result = remove_listener() - if inspect.isawaitable(result): - await result - except BaseException as err: - # Cancel EVERY listener even when an earlier remover - # raises: a skipped remover would leave a daily - # callback firing against a closed database after - # unload. The first error is retained and re-raised - # only after both removers (and the DB close) ran. - if unload_error is None: - unload_error = err + if remove_update_listener is not None: + result = remove_update_listener() + if inspect.isawaitable(result): + await result + except BaseException as err: + # A failing remover must not skip the coordinator shutdown + # below; the first error is retained and re-raised only + # after the teardown ran. + if unload_error is None: + unload_error = err finally: - # The coordinator's entity listeners must stop BEFORE the - # database closes: a scheduled refresh starting during the - # close await would touch a cleared connection. (The - # platform unload above already removed the entities; this - # clears the coordinator's own remaining listeners.) + # The coordinator's entity listeners must stop here: a + # scheduled refresh must never outlive the unload. coordinator = getattr(runtime_data, "coordinator", None) if coordinator is not None: shutdown = getattr(coordinator, "async_shutdown", None) if shutdown is not None: await shutdown() - database = getattr(runtime_data, "database", None) - if database is not None: - await database.close() # Domain-global services are torn down only once the FINAL entry # unloads (``hass.data[DOMAIN]`` is now empty), never when a sibling # entry is still loaded. diff --git a/custom_components/nestquest/admin_allowlist.py b/custom_components/nestquest/admin_allowlist.py deleted file mode 100644 index 92f2bb5..0000000 --- a/custom_components/nestquest/admin_allowlist.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.admin_allowlist` for the integration package. - -The admin allowlist business layer lives in -:mod:`custom_components.nestquest.core.admin_allowlist` (extracted in -task 510c1f78); this module re-exports it so existing -``from .admin_allowlist import ...`` references inside the integration -keep working. -""" -from __future__ import annotations - -from .core.admin_allowlist import * # noqa: F401,F403 diff --git a/custom_components/nestquest/children.py b/custom_components/nestquest/children.py deleted file mode 100644 index f936729..0000000 --- a/custom_components/nestquest/children.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.children` for the integration package. - -The children registry business layer lives in -:mod:`custom_components.nestquest.core.children` (extracted in -task 510c1f78); this module re-exports it so existing -``from .children import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.children import * # noqa: F401,F403 diff --git a/custom_components/nestquest/completion.py b/custom_components/nestquest/completion.py deleted file mode 100644 index d3b10b2..0000000 --- a/custom_components/nestquest/completion.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.completion` for the integration package. - -The completion business layer lives in -:mod:`custom_components.nestquest.core.completion` (extracted in -task 510c1f78); this module re-exports it so existing -``from .completion import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.completion import * # noqa: F401,F403 diff --git a/custom_components/nestquest/config_flow.py b/custom_components/nestquest/config_flow.py index 803b2f1..4f54b59 100644 --- a/custom_components/nestquest/config_flow.py +++ b/custom_components/nestquest/config_flow.py @@ -7,7 +7,7 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import CONF_ADMIN_USER_IDS, DOMAIN +from .const import DOMAIN class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -30,11 +30,11 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the user step of the config flow. - The HA user completing the flow is persisted into the entry - data as the initial admin allowlist copy: real HA does not - guarantee the flow context survives to setup, and entry data is - the persisted record first setup seeds from — the owner is - never locked out. + The integration needs no configuration to start (the API + connection is set in the options flow), so the user input is + stored as-is. The former admin-allowlist copy is gone: with + the database removed the allowlist lives in the API service's + own store, not on the entry. """ await self.async_set_unique_id(DOMAIN, raise_on_progress=False) if self._async_in_progress() or self._async_current_entries(): @@ -43,12 +43,4 @@ class NestQuestConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is None: return self.async_show_form(step_id="user") - data = dict(user_input) - flow_user_id = ( - self.context.get("user_id") - if isinstance(self.context, dict) - else None - ) - if flow_user_id: - data[CONF_ADMIN_USER_IDS] = [flow_user_id] - return self.async_create_entry(title="NestQuest", data=data) \ No newline at end of file + return self.async_create_entry(title="NestQuest", data=dict(user_input)) \ No newline at end of file diff --git a/custom_components/nestquest/dao_children.py b/custom_components/nestquest/dao_children.py deleted file mode 100644 index 6ee1e2e..0000000 --- a/custom_components/nestquest/dao_children.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.dao_children` for the integration package. - -The children/admin DAO lives in -:mod:`custom_components.nestquest.core.dao_children` (extracted in -task d0b691d8); this module re-exports it so existing -``from .dao_children import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.dao_children import * # noqa: F401,F403 -from .core.dao_children import _CONNECTION_LOCKS, _connection_lock # noqa: F401 diff --git a/custom_components/nestquest/dao_instances.py b/custom_components/nestquest/dao_instances.py deleted file mode 100644 index 1ac0c9e..0000000 --- a/custom_components/nestquest/dao_instances.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.dao_instances` for the integration package. - -The instances/completion-events DAO lives in -:mod:`custom_components.nestquest.core.dao_instances` -(extracted in task d0b691d8); this module re-exports it so existing -``from .dao_instances import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.dao_instances import * # noqa: F401,F403 -from .core.dao_instances import _resolve_today # noqa: F401 diff --git a/custom_components/nestquest/dao_meta.py b/custom_components/nestquest/dao_meta.py deleted file mode 100644 index bef9c16..0000000 --- a/custom_components/nestquest/dao_meta.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.dao_meta` for the integration package. - -The meta-state DAO lives in -:mod:`custom_components.nestquest.core.dao_meta` (extracted in task -d0b691d8); this module re-exports it so existing -``from .dao_meta import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.dao_meta import * # noqa: F401,F403 diff --git a/custom_components/nestquest/dao_presence.py b/custom_components/nestquest/dao_presence.py deleted file mode 100644 index 422ae17..0000000 --- a/custom_components/nestquest/dao_presence.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.dao_presence` for the integration package. - -The presence DAOs live in -:mod:`custom_components.nestquest.core.dao_presence` (extracted in -task d0b691d8); this module re-exports them so existing -``from .dao_presence import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.dao_presence import * # noqa: F401,F403 diff --git a/custom_components/nestquest/dao_rules.py b/custom_components/nestquest/dao_rules.py deleted file mode 100644 index 31c4ab9..0000000 --- a/custom_components/nestquest/dao_rules.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.dao_rules` for the integration package. - -The schedule-rules/quest-definitions DAO lives in -:mod:`custom_components.nestquest.core.dao_rules` (extracted in task -d0b691d8); this module re-exports it so existing -``from .dao_rules import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.dao_rules import * # noqa: F401,F403 -from .core.dao_rules import ( # noqa: F401 - _UNSET, - _validate_date, - _validate_time, - _validate_window, -) diff --git a/custom_components/nestquest/db.py b/custom_components/nestquest/db.py deleted file mode 100644 index b13a2e0..0000000 --- a/custom_components/nestquest/db.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.db` for the integration package. - -The async SQLite wrapper lives in :mod:`custom_components.nestquest.core.db` (extracted in task -d0b691d8) and is Home-Assistant-free: :class:`NestQuestDatabase` takes -an executor callable rather than a ``hass`` object. This module -re-exports it and provides :func:`make_database`, a thin adapter that -constructs a wrapper backed by ``hass.async_add_executor_job``, so the -integration constructs the database the same way it always did. -""" -from __future__ import annotations - -from typing import Any, TypeVar - -from homeassistant.core import HomeAssistant - -from .core.db import ExecutionResult, NestQuestDatabase - -__all__ = ["ExecutionResult", "NestQuestDatabase", "make_database"] - -T = TypeVar("T") - - -def make_database(hass: HomeAssistant) -> NestQuestDatabase: - """Construct a :class:`NestQuestDatabase` wired to ``hass``'s executor. - - Mirrors the pre-extraction ``NestQuestDatabase(hass)`` call: the - wrapper's executor is ``hass.async_add_executor_job``, so every - sqlite3 call runs on the HA executor thread and the event loop - never blocks. - """ - return NestQuestDatabase(hass.async_add_executor_job) diff --git a/custom_components/nestquest/events.py b/custom_components/nestquest/events.py deleted file mode 100644 index 5a67c81..0000000 --- a/custom_components/nestquest/events.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Re-export / HA wiring of :mod:`custom_components.nestquest.core.events` for the integration package. - -The transition-event payload construction lives in -:mod:`custom_components.nestquest.core.events` (extracted in -task 510c1f78) and is HA-free: it builds and RETURNS the events. This -shim keeps the integration's old ``fire_quest_completed`` / -``fire_quest_uncompleted`` API for direct callers — since Feature 18 -the HA service surface no longer fires transition events itself (the -API service publishes them on its SSE stream and the integration's -subscription re-fires them) — reads the HA-local timezone from -``hass.config.time_zone`` to derive ``today``, asks the core builder -for the events, and fires each on ``hass.bus`` with the documented -payloads. The four blueprints keep working with identical payloads. - -The completed event is fired BEFORE the day-complete rule evaluates: -``fire_quest_completed`` asks -:func:`build_quest_completed_event` for the completed event and fires -it immediately, THEN reads the HA-local ``today``, asks -:func:`build_child_day_complete_event` for the day-complete event, and -fires it (if any) afterwards. This restores the pre-extraction -ordering — a failure in the day-complete DB reads or the ZoneInfo -lookup cannot suppress the completed event. -""" -from __future__ import annotations - -import datetime -from zoneinfo import ZoneInfo - -from .core.db import NestQuestDatabase -from .core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - build_quest_uncompleted_events, -) - -__all__ = ["fire_quest_completed", "fire_quest_uncompleted"] - - -async def fire_quest_completed( - hass, - database: NestQuestDatabase, - instance_id: int, - *, - was_on_time: bool | None, -) -> None: - """Fire ``nestquest_quest_completed`` and, when the completion - cleared the child's whole day, ``nestquest_child_day_complete``. - - Thin HA wiring over the HA-free - :func:`custom_components.nestquest.core.events.build_quest_completed_event` - and - :func:`build_child_day_complete_event`: the completed event is - built and fired FIRST (so a failure in the day-complete DB reads - or the HA-local ``today`` lookup cannot suppress it), then the - HA-local ``today`` (the day-complete rule's input) is read from - ``hass.config.time_zone`` and the day-complete builder is asked - whether the child's whole day cleared. Identical payloads and - day-complete rule to the pre-extraction implementation. - """ - completed = await build_quest_completed_event( - database, - instance_id, - was_on_time=was_on_time, - ) - hass.bus.async_fire(*completed) - - today = datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() - day_complete = await build_child_day_complete_event( - database, completed[1], today=today - ) - if day_complete is not None: - hass.bus.async_fire(*day_complete) - - -async def fire_quest_uncompleted( - hass, database: NestQuestDatabase, instance_id: int -) -> None: - """Fire ``nestquest_quest_uncompleted`` for an actual reversal.""" - events = await build_quest_uncompleted_events(database, instance_id) - for event_type, payload in events: - hass.bus.async_fire(event_type, payload) diff --git a/custom_components/nestquest/materialize.py b/custom_components/nestquest/materialize.py deleted file mode 100644 index a6cb1b1..0000000 --- a/custom_components/nestquest/materialize.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.materialize` for the integration package. - -The materialization walk lives in -:mod:`custom_components.nestquest.core.materialize` (extracted in -task 510c1f78); this module re-exports it so existing -``from .materialize import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.materialize import * # noqa: F401,F403 diff --git a/custom_components/nestquest/migrations.py b/custom_components/nestquest/migrations.py deleted file mode 100644 index d51d3c7..0000000 --- a/custom_components/nestquest/migrations.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.migrations` for the integration package. - -The versioned migration runner lives in -:mod:`custom_components.nestquest.core.migrations` -(extracted in task d0b691d8); this module re-exports it so existing -``from .migrations import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.migrations import * # noqa: F401,F403 diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 29adee9..865d992 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -1,4 +1,15 @@ -"""Options flow for NestQuest post-setup settings.""" +"""Options flow for NestQuest post-setup settings. + +Since the DB removal (Feature 18) the integration is a thin client of +the NestQuest API service: the settings that only served the local +database (the generation horizon, the day rollover, the notification +defaults, the admin allowlist) are gone — the API service owns those in +its own settings store, and the automations take their behaviour from +blueprint inputs. What remains is what the CLIENT itself consumes: the +panel-plane API connection (base URL + token), the shared coordinator's +refresh interval, the last-good staleness threshold, and the +panel-facing idle timeout. +""" from __future__ import annotations from typing import Any @@ -6,52 +17,25 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import config_validation as cv from .const import ( - CONF_ADMIN_USER_IDS, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_AFTERNOON_REMINDER_TIME, CONF_API_BASE_URL, - CONF_CELEBRATION_ENABLED, - CONF_DAY_ROLLOVER_TIME, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_END_OF_DAY_REPORT_TIME, - CONF_HORIZON_DAYS, - CONF_MORNING_SUMMARY_ENABLED, - CONF_MORNING_SUMMARY_TIME, - CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_PANEL_TOKEN, CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_API_BASE_URL, - DEFAULT_AFTERNOON_REMINDER_TIME, - DEFAULT_AUTOMATION_ENABLED, - DEFAULT_DAY_ROLLOVER_TIME, - DEFAULT_END_OF_DAY_REPORT_TIME, - DEFAULT_HORIZON_DAYS, - DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_TOKEN, DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, - DOMAIN, MIN_UPDATE_INTERVAL, - TIME_PATTERN, ) -from .admin_allowlist import list_admin_ids, set_admin_ids from .api_client import is_valid_base_url -MIN_HORIZON_DAYS = 1 MIN_PANEL_IDLE_TIMEOUT = 30 -def _parse_time(value: str) -> bool: - """Return True when value is a valid 24-hour HH:MM string.""" - return TIME_PATTERN.fullmatch(value) is not None - - def _async_validate( user_input: dict[str, Any], current: dict[str, Any] | None = None, @@ -66,18 +50,6 @@ def _async_validate( """ errors: dict[str, str] = {} - horizon_days = user_input.get(CONF_HORIZON_DAYS) - if ( - isinstance(horizon_days, bool) - or not isinstance(horizon_days, int) - or horizon_days < MIN_HORIZON_DAYS - ): - errors[CONF_HORIZON_DAYS] = "invalid" - - day_rollover_time = user_input.get(CONF_DAY_ROLLOVER_TIME) - if not isinstance(day_rollover_time, str) or not _parse_time(day_rollover_time): - errors[CONF_DAY_ROLLOVER_TIME] = "invalid_time" - panel_idle_timeout = user_input.get(CONF_PANEL_IDLE_TIMEOUT) if ( isinstance(panel_idle_timeout, bool) @@ -125,41 +97,6 @@ def _async_validate( if staleness < effective_interval: errors[CONF_SNAPSHOT_STALENESS] = "invalid_staleness" - # The notification section (Feature 11) is OPTIONAL in raw submits - # the same way: a missing key keeps the current value. The three - # times are strict HH:MM; the notify target is a non-empty HA - # service name; the toggles are real booleans. - for time_key in ( - CONF_MORNING_SUMMARY_TIME, - CONF_AFTERNOON_REMINDER_TIME, - CONF_END_OF_DAY_REPORT_TIME, - ): - time_value = user_input.get(time_key) - if time_value is not None and ( - not isinstance(time_value, str) or not _parse_time(time_value) - ): - errors[time_key] = "invalid_time" - notify_target = user_input.get(CONF_NOTIFY_TARGET) - if notify_target is not None and not isinstance(notify_target, str): - errors[CONF_NOTIFY_TARGET] = "invalid" - elif isinstance(notify_target, str) and notify_target != ( - notify_target.strip() - ): - # An EMPTY target is valid — it means "no notifications - # configured yet" and is the shipped default; a value that - # differs from its own trim (padded or whitespace-only) is - # malformed and rejected. - errors[CONF_NOTIFY_TARGET] = "invalid" - for toggle_key in ( - CONF_MORNING_SUMMARY_ENABLED, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_CELEBRATION_ENABLED, - ): - toggle_value = user_input.get(toggle_key) - if toggle_value is not None and not isinstance(toggle_value, bool): - errors[toggle_key] = "invalid" - # The panel-plane API connection (Feature 18) is OPTIONAL in raw # submits the same way: a missing key keeps the current value. The # base URL must be a full http(s) URL with a host — the same check @@ -182,261 +119,78 @@ def _async_validate( return errors -def _strict_bool(value: object) -> bool: - """Require a real bool; voluptuous ``bool`` would coerce strings.""" - if not isinstance(value, bool): - raise vol.Invalid(f"expected a boolean, got {value!r}") - return value - - -def _build_schema( - current: dict[str, Any], - *, - admin_choices: dict[str, str] | None = None, - admin_default: list[str] | None = None, -) -> vol.Schema: +def _build_schema(current: dict[str, Any]) -> vol.Schema: """Build the form schema, pre-filled with the current values. - The admin picker is a multi-select of EXISTING Home Assistant users - (never a free-text field — the allowlist stores user IDs); its - default is the current database allowlist, so an untouched submit - re-saves exactly who is already allowed. The notification section - (Feature 11) carries the household's notify target, the three - automation times, and four enable toggles — defaults from - :mod:`~.const`, never a hard-coded personal target. + The API connection (Feature 18) carries the base URL the API client + talks to and the panel service token it presents as the Bearer + credential. Schema-validated submits always carry every field (the + defaults pre-fill from the stored values); raw callers omitting one + keep its stored value (see _async_validate). """ - fields: dict[Any, Any] = { - vol.Required( - CONF_HORIZON_DAYS, default=current[CONF_HORIZON_DAYS] - ): int, - vol.Required( - CONF_DAY_ROLLOVER_TIME, default=current[CONF_DAY_ROLLOVER_TIME] - ): str, - vol.Required( - CONF_PANEL_IDLE_TIMEOUT, default=current[CONF_PANEL_IDLE_TIMEOUT] - ): int, - vol.Required( - CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] - ): int, - # The last-good staleness threshold (Feature 18): how long the - # coordinator keeps serving the cached snapshot after the API - # stops answering. Schema-validated submits always carry it - # (the default pre-fills from the stored value); raw callers - # omitting it keep their stored value (see _async_validate). - vol.Required( - CONF_SNAPSHOT_STALENESS, default=current[CONF_SNAPSHOT_STALENESS] - ): int, - vol.Required( - CONF_NOTIFY_TARGET, default=current[CONF_NOTIFY_TARGET] - ): str, - vol.Required( - CONF_MORNING_SUMMARY_TIME, - default=current[CONF_MORNING_SUMMARY_TIME], - ): str, - vol.Required( - CONF_AFTERNOON_REMINDER_TIME, - default=current[CONF_AFTERNOON_REMINDER_TIME], - ): str, - vol.Required( - CONF_END_OF_DAY_REPORT_TIME, - default=current[CONF_END_OF_DAY_REPORT_TIME], - ): str, - vol.Required( - CONF_MORNING_SUMMARY_ENABLED, - default=current[CONF_MORNING_SUMMARY_ENABLED], - ): _strict_bool, - vol.Required( - CONF_AFTERNOON_REMINDER_ENABLED, - default=current[CONF_AFTERNOON_REMINDER_ENABLED], - ): _strict_bool, - vol.Required( - CONF_END_OF_DAY_REPORT_ENABLED, - default=current[CONF_END_OF_DAY_REPORT_ENABLED], - ): _strict_bool, - vol.Required( - CONF_CELEBRATION_ENABLED, - default=current[CONF_CELEBRATION_ENABLED], - ): _strict_bool, - # Panel-plane API connection (Feature 18): the base URL the API - # client talks to and the panel service token it presents as - # the Bearer credential. Schema-validated submits always carry - # both (the defaults pre-fill from the stored values); raw - # callers omitting them keep their stored values (see - # _async_validate). - vol.Required( - CONF_API_BASE_URL, default=current[CONF_API_BASE_URL] - ): str, - vol.Required( - CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN] - ): str, - } - if admin_choices is not None: - # HA's supported multi-select validator: the frontend can - # serialize the field as a real picker of the given choices. - fields[ - vol.Optional( - CONF_ADMIN_USER_IDS, default=list(admin_default or []) - ) - ] = cv.multi_select(admin_choices) - return vol.Schema(fields) + return vol.Schema( + { + vol.Required( + CONF_PANEL_IDLE_TIMEOUT, + default=current[CONF_PANEL_IDLE_TIMEOUT], + ): int, + vol.Required( + CONF_UPDATE_INTERVAL, default=current[CONF_UPDATE_INTERVAL] + ): int, + # The last-good staleness threshold (Feature 18): how long + # the coordinator keeps serving the cached snapshot after + # the API stops answering. + vol.Required( + CONF_SNAPSHOT_STALENESS, + default=current[CONF_SNAPSHOT_STALENESS], + ): int, + # Panel-plane API connection (Feature 18): the base URL the + # API client talks to and the panel service token it + # presents as the Bearer credential. + vol.Required( + CONF_API_BASE_URL, default=current[CONF_API_BASE_URL] + ): str, + vol.Required( + CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN] + ): str, + } + ) class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): """Handle the options flow for NestQuest.""" - def _async_database(self): - """Return the runtime database for this entry, or None. - - Real HA only opens the options flow for an existing entry, so - the runtime record (and its database) exists; the None branch - covers direct construction in tests and degraded states. - """ - hass_data = getattr(self.hass, "data", None) - if not isinstance(hass_data, dict): - return None - record = hass_data.get(DOMAIN, {}).get(self.config_entry.entry_id) - database = getattr(record, "database", None) - return database if getattr(database, "connected", False) else None - - async def _async_admin_choices(self) -> dict[str, str]: - """Map HA user id -> display name for the picker. - - Real Home Assistant always provides ``hass.auth``; a missing - one (direct construction in tests, degraded harness) degrades - to an empty picker rather than crashing the form. - """ - auth = getattr(self.hass, "auth", None) - if auth is None: - return {} - users = await auth.async_get_users() - return { - user.id: user.name - for user in users - if getattr(user, "id", None) - } - - async def _async_current_admin_ids(self) -> list[str]: - """Return the current allowlist, or [] without a database.""" - database = self._async_database() - if database is None: - return [] - return await list_admin_ids(database) - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Manage the NestQuest options.""" - admin_choices = await self._async_admin_choices() - admin_default = await self._async_current_admin_ids() if user_input is None: return self.async_show_form( step_id="init", - data_schema=_build_schema( - self._current_values(), - admin_choices=admin_choices, - admin_default=admin_default, - ), + data_schema=_build_schema(self._current_values()), ) errors = _async_validate(user_input, self._current_values()) - # The admin ids are validated only when the key is present: - # schema-validated submits always carry it (vol.Optional's - # default fills from the current allowlist), while raw callers - # omitting the key keep the field untouched. - admin_ids_present = CONF_ADMIN_USER_IDS in user_input - admin_ids = user_input.get(CONF_ADMIN_USER_IDS) if errors: return self.async_show_form( step_id="init", - data_schema=_build_schema( - self._current_values(), - admin_choices=admin_choices, - admin_default=admin_default, - ), - errors=errors, - ) - if admin_ids_present and ( - not isinstance(admin_ids, list) - or any(not isinstance(value, str) for value in admin_ids) - ): - errors[CONF_ADMIN_USER_IDS] = "invalid_admin" - elif admin_ids_present and not admin_ids: - # An empty allowlist fails closed: saving zero admins is - # refused, never silently applied. - errors[CONF_ADMIN_USER_IDS] = "no_admins" - elif admin_ids_present and any( - value not in admin_choices for value in admin_ids - ): - # The schema enforces membership for real HA submits; this - # backs the raw-input path up so an unknown id can never - # reach the allowlist. - errors[CONF_ADMIN_USER_IDS] = "invalid_admin" - elif admin_ids_present and len(set(admin_ids)) != len(admin_ids): - # set_admin_ids refuses duplicates with ValueError; a flow - # submission must surface that as a field error instead of - # an unhandled crash. - errors[CONF_ADMIN_USER_IDS] = "invalid_admin" - if errors: - return self.async_show_form( - step_id="init", - data_schema=_build_schema( - self._current_values(), - admin_choices=admin_choices, - admin_default=admin_default, - ), + data_schema=_build_schema(self._current_values()), errors=errors, ) - # Apply the picker's selection to the allowlist when the - # runtime database is reachable; the selection is always - # stored on the entry as the disaster-recovery copy. - database = self._async_database() - if database is not None and admin_ids_present: - await set_admin_ids(database, admin_ids) - data = { - CONF_HORIZON_DAYS: user_input[CONF_HORIZON_DAYS], - CONF_DAY_ROLLOVER_TIME: user_input[CONF_DAY_ROLLOVER_TIME], - CONF_PANEL_IDLE_TIMEOUT: user_input[CONF_PANEL_IDLE_TIMEOUT], - CONF_UPDATE_INTERVAL: user_input.get( - CONF_UPDATE_INTERVAL, - self._current_values()[CONF_UPDATE_INTERVAL], - ), - CONF_SNAPSHOT_STALENESS: user_input.get( - CONF_SNAPSHOT_STALENESS, - self._current_values()[CONF_SNAPSHOT_STALENESS], - ), - } - # The notification section (Feature 11) stores the household's - # choices the same fall-back-to-current way, so raw submits - # that predate the section keep their stored values. + # Every remaining key is stored the fall-back-to-current way, so + # raw submits that predate a section keep that section's stored + # values. current = self._current_values() - for notification_key in ( - CONF_NOTIFY_TARGET, - CONF_MORNING_SUMMARY_TIME, - CONF_AFTERNOON_REMINDER_TIME, - CONF_END_OF_DAY_REPORT_TIME, - CONF_MORNING_SUMMARY_ENABLED, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_CELEBRATION_ENABLED, + data: dict[str, Any] = {} + for key in ( + CONF_PANEL_IDLE_TIMEOUT, + CONF_UPDATE_INTERVAL, + CONF_SNAPSHOT_STALENESS, + CONF_API_BASE_URL, + CONF_PANEL_TOKEN, ): - data[notification_key] = user_input.get( - notification_key, current[notification_key] - ) - # The API connection section (Feature 18) stores the base URL - # and panel token the same fall-back-to-current way, so raw - # submits that predate the section keep their stored values. - for api_key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN): - data[api_key] = user_input.get(api_key, current[api_key]) - if admin_ids_present: - data[CONF_ADMIN_USER_IDS] = admin_ids - elif admin_default: - # HA replaces entry.options wholesale, so an omitted picker - # field must carry the CURRENT allowlist forward: dropping - # it would leave only the stale config-flow copy in entry - # data, and a later database loss would resurrect admins - # the owner has since removed. - data[CONF_ADMIN_USER_IDS] = list(admin_default) + data[key] = user_input.get(key, current[key]) return self.async_create_entry(title="NestQuest", data=data) def _current_values(self) -> dict[str, Any]: @@ -445,19 +199,9 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): entry_data = getattr(self.config_entry, "data", None) or {} values: dict[str, Any] = {} for key, default in ( - (CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS), - (CONF_DAY_ROLLOVER_TIME, DEFAULT_DAY_ROLLOVER_TIME), (CONF_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_IDLE_TIMEOUT), (CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL), (CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS), - (CONF_NOTIFY_TARGET, ""), - (CONF_MORNING_SUMMARY_TIME, DEFAULT_MORNING_SUMMARY_TIME), - (CONF_AFTERNOON_REMINDER_TIME, DEFAULT_AFTERNOON_REMINDER_TIME), - (CONF_END_OF_DAY_REPORT_TIME, DEFAULT_END_OF_DAY_REPORT_TIME), - (CONF_MORNING_SUMMARY_ENABLED, DEFAULT_AUTOMATION_ENABLED), - (CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED), - (CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED), - (CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED), (CONF_API_BASE_URL, DEFAULT_API_BASE_URL), (CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN), ): @@ -467,4 +211,4 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): values[key] = entry_data[key] else: values[key] = default - return values \ No newline at end of file + return values diff --git a/custom_components/nestquest/presence.py b/custom_components/nestquest/presence.py deleted file mode 100644 index 56538b3..0000000 --- a/custom_components/nestquest/presence.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.presence` for the integration package. - -The presence and custody model lives in -:mod:`custom_components.nestquest.core.presence` (extracted in -task 510c1f78); this module re-exports it so existing -``from .presence import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.presence import * # noqa: F401,F403 diff --git a/custom_components/nestquest/presence_management.py b/custom_components/nestquest/presence_management.py deleted file mode 100644 index 92f89be..0000000 --- a/custom_components/nestquest/presence_management.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package. - -The presence-write business layer lives in -:mod:`custom_components.nestquest.core.presence_management` (added for -task e4a9d31b); this module re-exports it so the integration's service -handlers call the ONE implementation the API admin plane shares. -""" -from __future__ import annotations - -from .core.presence_management import * # noqa: F401,F403 diff --git a/custom_components/nestquest/quest_definitions.py b/custom_components/nestquest/quest_definitions.py deleted file mode 100644 index 099fc46..0000000 --- a/custom_components/nestquest/quest_definitions.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.quest_definitions` for the integration package. - -The quest definitions business layer lives in -:mod:`custom_components.nestquest.core.quest_definitions` (extracted in -task 510c1f78); this module re-exports it so existing -``from .quest_definitions import ...`` references inside the integration -keep working. -""" -from __future__ import annotations - -from .core.quest_definitions import * # noqa: F401,F403 diff --git a/custom_components/nestquest/recurrence.py b/custom_components/nestquest/recurrence.py deleted file mode 100644 index 7e72994..0000000 --- a/custom_components/nestquest/recurrence.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.recurrence` for the integration package. - -The recurrence model lives in :mod:`custom_components.nestquest.core.recurrence` -(extracted in task d0b691d8); this module re-exports it so existing -``from .recurrence import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.recurrence import * # noqa: F401,F403 diff --git a/custom_components/nestquest/schema.py b/custom_components/nestquest/schema.py deleted file mode 100644 index 6ed278a..0000000 --- a/custom_components/nestquest/schema.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.schema` for the integration package. - -The declarative schema DDL lives in -:mod:`custom_components.nestquest.core.schema` (extracted in -task d0b691d8); this module re-exports it so existing -``from .schema import ...`` references inside the integration keep -working. -""" -from __future__ import annotations - -from .core.schema import * # noqa: F401,F403 diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index ad9f146..715d4cf 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -54,7 +54,7 @@ def _require_runtime(hass: HomeAssistant, find_runtime: FindRuntime): runtime = find_runtime(hass) if runtime is None: raise HomeAssistantError( - "NestQuest has no live database; cannot run this service" + "NestQuest is not loaded; cannot run this service" ) return runtime diff --git a/custom_components/nestquest/store.py b/custom_components/nestquest/store.py deleted file mode 100644 index e87a7d7..0000000 --- a/custom_components/nestquest/store.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Re-export of :mod:`custom_components.nestquest.core.store` for the integration package. - -The path resolver lives in -:mod:`custom_components.nestquest.core.store` (extracted in task -d0b691d8) and is Home-Assistant-free: -:func:`custom_components.nestquest.core.store.async_get_db_path` -takes the full database path. This module re-exports it and provides -the original ``async_get_db_path(hass)`` signature so the integration -resolves the path from ``hass.config.path`` exactly as before. -""" -from __future__ import annotations - -from pathlib import Path - -from homeassistant.core import HomeAssistant - -from .core.const import SQLITE_DB_FILENAME -from .core.store import async_get_db_path as _core_async_get_db_path -from .core.store import _ensure_dir - -__all__ = ["async_get_db_path", "_ensure_dir"] - - -async def async_get_db_path(hass: HomeAssistant) -> Path: - """Return the absolute path to the NestQuest SQLite database file. - - Resolves the database file from ``hass.config.path`` and delegates to - :func:`custom_components.nestquest.core.store.async_get_db_path`, - which ensures the parent directory exists off the event loop via - :func:`asyncio.to_thread`. - """ - return await _core_async_get_db_path(hass.config.path(SQLITE_DB_FILENAME)) diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index 1853eb4..446757a 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -18,25 +18,19 @@ "step": { "init": { "title": "NestQuest Settings", - "description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.", + "description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.", "data": { - "horizon_days": "Generation horizon (days)", - "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", "snapshot_staleness": "Last-good snapshot staleness (seconds)", "api_base_url": "NestQuest API base URL (http://host:port)", - "panel_token": "Panel service token", - "admin_user_ids": "Admin users (parents)" + "panel_token": "Panel service token" } } }, "errors": { "invalid": "Invalid value.", - "invalid_time": "Time must use 24-hour HH:MM format.", "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", - "invalid_staleness": "Last-good snapshot staleness must be at least the update interval.", - "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", - "invalid_admin": "Invalid admin selection." + "invalid_staleness": "Last-good snapshot staleness must be at least the update interval." } } } diff --git a/custom_components/nestquest/sweep.py b/custom_components/nestquest/sweep.py deleted file mode 100644 index 937f969..0000000 --- a/custom_components/nestquest/sweep.py +++ /dev/null @@ -1,72 +0,0 @@ -"""The nightly missed-quest sweep (Feature 11) — the Home Assistant shim. - -The sweep's policy is the HA-free core's :mod:`~.core.sweep`: the -watermark read → ``list_missed_unswept`` query → payload build → -watermark span, all inside the per-database sweep lock, shared with -the API plane. This module is the integration's thin adapter over it: -at the configured day-rollover time (the same HA-local listener that -re-materializes the rolling horizon) and once at setup it calls the -core's :func:`~.core.sweep.run_missed_sweep` and fires each returned -event on ``hass.bus`` — ONE ``nestquest_quest_missed`` per past-due -open instance with the documented payload. The core never touches -Home Assistant; the firing lives here. - -Idempotency is the core's watermark: after a run, ``nestquest_meta_state`` -records the HA-local date the sweep ran, and the next run only sweeps -instances due in ``[watermark, today)`` — a second run the same night -fires nothing new, and a run after days of downtime sweeps exactly the -accumulated window once. An instance swept as missed that is later -completed simply gains its completion event; the sweep is read-only -over the domain tables (``missed`` is derived state, never an -event_type — Feature 08's contract). -""" -from __future__ import annotations - -import datetime -import logging -from zoneinfo import ZoneInfo - -from .core.sweep import ( - SWEEP_WATERMARK_KEY, - run_missed_sweep as _run_core_missed_sweep, -) -from .db import NestQuestDatabase - -LOGGER = logging.getLogger(__name__) - -__all__ = ["SWEEP_WATERMARK_KEY", "run_missed_sweep"] - - -async def run_missed_sweep( - hass, - database: NestQuestDatabase, - *, - today: datetime.date | None = None, -) -> int: - """Fire one missed event per unswept past-due open instance. - - ``today`` optionally pins the HA-local date (threaded like the - materialization and completion layers); when omitted it is read - from ``hass.config.time_zone``. Returns the number of events - fired. Fires nothing and returns 0 when the watermark already - covers ``today`` (a same-night rerun) — the watermark read, the - query, the payload build and the watermark write all live in the - core's locked - :func:`~.core.sweep.run_missed_sweep`; this shim only fires each - returned event on the bus and counts them. - """ - today_date = ( - today - if today is not None - else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() - ) - events = await _run_core_missed_sweep(database, today=today_date) - for event_type, payload in events: - hass.bus.async_fire(event_type, payload) - fired = len(events) - LOGGER.info( - "NestQuest missed sweep for %s fired %d event(s)", - today_date.isoformat(), - fired, - ) - return fired \ No newline at end of file diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index 1853eb4..446757a 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -18,25 +18,19 @@ "step": { "init": { "title": "NestQuest Settings", - "description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.", + "description": "Adjust how the NestQuest API client polls and reaches the NestQuest API service.", "data": { - "horizon_days": "Generation horizon (days)", - "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", "snapshot_staleness": "Last-good snapshot staleness (seconds)", "api_base_url": "NestQuest API base URL (http://host:port)", - "panel_token": "Panel service token", - "admin_user_ids": "Admin users (parents)" + "panel_token": "Panel service token" } } }, "errors": { "invalid": "Invalid value.", - "invalid_time": "Time must use 24-hour HH:MM format.", "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", - "invalid_staleness": "Last-good snapshot staleness must be at least the update interval.", - "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", - "invalid_admin": "Invalid admin selection." + "invalid_staleness": "Last-good snapshot staleness must be at least the update interval." } } } diff --git a/tests/conftest.py b/tests/conftest.py index 8af8125..efa617f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,6 @@ pytest -q``); it is never meant to run against a real HA install. from __future__ import annotations import asyncio -import datetime import importlib.util import inspect import sys @@ -742,160 +741,92 @@ def wire_entry_to_registry(entry, registry: ListenerRegistry): return entry -class LazyLocalSnapshotClient: - """A test client that serves the snapshot built LOCALLY. +class StubSnapshotClient: + """A no-network stand-in for the API client's snapshot surface. - 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. - - ``complete_instance`` bridges the panel complete route the same - way (the ``nestquest.complete_quest`` proxy calls it through the - API client): it resolves the entry's database and delegates to the - SAME core completion the API route delegates to, with the panel - actor shape, and answers the route's ``{"status": ...}`` body. - Like the real API service it publishes its transition events on - the SSE stream — NOT on the Home Assistant bus and NOT from the - service handler; tests that assert bus events drive them through - the SSE subscription the way production delivers them. - - 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. + Answers ``get_snapshot()`` from a scripted queue of outcomes — a + payload dict or an exception per call, consumed in order — and + records every call. When the queue is exhausted it re-serves the + last outcome, so setup's first refresh and a test's explicit + refreshes poll the same scripted state. Register it (or any + scripted client) with :func:`set_coordinator_client` BEFORE setup. """ - def __init__(self, hass, entry_id: str) -> None: - self._hass = hass - self._entry_id = entry_id + def __init__(self, *outcomes) -> None: self.calls = 0 - #: Every ``(instance_id, CompletionResult)`` this stand-in - # produced, in call order — tests read ``appended`` / - # ``was_on_time`` off these to check the completion verdicts. - self.completions = [] - #: The SSE frames the stand-in published for its completions - # (the API service's stream content), drained by - #: :func:`refire_api_transitions`. + self._outcomes = list(outcomes) + self._last = outcomes[-1] if outcomes else None + #: The SSE frames the stand-in "published" (the API service's + # stream content), drained by :func:`refire_api_transitions`. self.published_frames = [] - def _live_runtime(self): - """The entry's runtime record, or ``None`` before/after setup.""" - from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN - - 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 None - return runtime - - def _live_database(self): - """The entry's open database, or ``None`` before/after setup.""" - runtime = self._live_runtime() - return None if runtime is None else runtime.database - 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._live_runtime() - if runtime is None: - return { - "today_iso": datetime.date.today().isoformat(), - "cycle_day": 0, - "children": [], - } - database = runtime.database - 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 - ], - } + 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 - async def complete_instance(self, instance_id: int, actor_child_id: int): - """The panel complete route's stand-in: complete through the - SAME core completion the route delegates to, panel actor - shape, publish the route's SSE transition frames (built by the - same core builders, appended completions only — see - ``api/routes_panel.py``), and answer the route's - ``{"status": ...}`` body.""" - import datetime - from custom_components.nestquest.completion import complete_instance - from custom_components.nestquest.core import events as core_events +class ScriptedPanelClient(StubSnapshotClient): + """Snapshot + completion stand-in for the API service's panel plane. - result = await complete_instance( - self._live_database(), - instance_id, - actor_source="panel", - actor_child_id=actor_child_id, - ) - self.completions.append((instance_id, result)) - if result.appended: - # The route publishes the transition the moment the core - # write lands (appended completions only), evaluating the - # day-complete rule against the API host's local date — - # the frames queue here for the SSE stream to carry. - event_type, payload = await core_events.build_quest_completed_event( - self._live_database(), - instance_id, - was_on_time=result.was_on_time, - ) - self.published_frames.append((event_type, payload)) - now = datetime.datetime.now().astimezone() - day_complete = await core_events.build_child_day_complete_event( - self._live_database(), payload, today=now.date() - ) - if day_complete is not None: - self.published_frames.append(day_complete) - return {"status": str(result)} + Serves the scripted snapshot outcomes (like + :class:`StubSnapshotClient`), records ``complete_instance`` calls + the way the panel route receives them, optionally fails them the + way the client types the failure, and carries the SSE frames the + API "published" in ``published_frames`` for + :func:`refire_api_transitions` to deliver. Everything the + integration reads over the wire, no network. + """ + + def __init__(self, *outcomes) -> None: + super().__init__(*outcomes) + self.complete_calls: list[tuple[int, int]] = [] + self.published_frames: list[tuple[str, dict]] = [] + #: When set, the next ``complete_instance`` raises this instead + # of succeeding (a typed NestQuestApiError in production). + self.fail_complete_with: BaseException | None = None + + async def complete_instance( + self, instance_id: int, actor_child_id: int + ) -> dict: + self.complete_calls.append((instance_id, actor_child_id)) + if self.fail_complete_with is not None: + raise self.fail_complete_with + return {"status": "done"} + + +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 @pytest.fixture(autouse=True) -def coordinator_serves_local_snapshot(): - """Default every test entry's coordinator to the local-snapshot client. +def coordinator_client_from_test_override(): + """Resolve every entry's coordinator client through the test seam. 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. + ``coordinator_client_from_entry``; this fixture swaps that factory + for a test one handing out the client registered with + :func:`set_coordinator_client`, or — for an entry with no scripted + client — a stub serving an EMPTY but valid household snapshot (the + shape the API's panel route returns before any child exists; the + real client would need aiohttp, which this mock-only harness does + not provide). """ + import datetime + import unittest.mock as mock + import custom_components.nestquest as nq_mod import custom_components.nestquest.coordinator as coordinator_module @@ -907,9 +838,13 @@ def coordinator_serves_local_snapshot(): 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 + return StubSnapshotClient( + { + "today_iso": datetime.date.today().isoformat(), + "cycle_day": 0, + "children": [], + } + ) _COORDINATOR_CLIENT_OVERRIDES.clear() patcher = mock.patch.object( @@ -921,14 +856,6 @@ def coordinator_serves_local_snapshot(): _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 - - async def refire_api_transitions(hass, entry, client): """Deliver the SSE frames the stand-in client has published so far through the integration's SSE subscription — the production event @@ -1205,42 +1132,6 @@ def make_config_flow_entry(): return _factory -def freeze_nestquest_clock( - monkeypatch, instant: datetime.datetime -) -> tuple[datetime.datetime, list]: - """Freeze the integration's ``datetime`` clock to a fixed aware instant. - - Only ``custom_components.nestquest``'s ``datetime`` binding is patched, - so the generation path's ``datetime.datetime.now`` reads this instant - (localized to whatever time zone it asks for) while every other module - and the test harness keep the real clock. Returns ``(frozen, requested)`` - where ``frozen`` is the instant normalized to UTC and ``requested`` is - the list of ``tzinfo`` objects the code under test passed to ``now`` — - so a caller can prove the generation path actually requested the - configured HA time zone rather than silently reading UTC. - """ - import custom_components.nestquest as nestquest - - frozen = instant.astimezone(datetime.timezone.utc) - requested: list = [] - - class _FrozenDatetime(datetime.datetime): - @classmethod - def now(cls, tz=None): - requested.append(tz) - if tz is None: - return frozen.replace(tzinfo=None) - return frozen.astimezone(tz) - - fake_module = SimpleNamespace( - datetime=_FrozenDatetime, - timedelta=datetime.timedelta, - date=datetime.date, - ) - monkeypatch.setattr(nestquest, "datetime", fake_module) - return frozen, requested - - # --------------------------------------------------------------------------- # Admin-plane API fixtures (shared by the admin-plane test modules). # diff --git a/tests/test_admin_allowlist.py b/tests/test_admin_allowlist.py index 1ca1224..800d0fe 100644 --- a/tests/test_admin_allowlist.py +++ b/tests/test_admin_allowlist.py @@ -1,21 +1,18 @@ -"""Tests for admin_allowlist.py: the Feature 03 allowlist business layer.""" +"""Tests for the Feature 03 admin allowlist business layer (core.admin_allowlist).""" from __future__ import annotations import asyncio -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest -from custom_components.nestquest import async_setup_entry, async_unload_entry -from custom_components.nestquest.admin_allowlist import ( +from custom_components.nestquest.core.admin_allowlist import ( list_admin_ids, seed_setup_admin, set_admin_ids, ) -from custom_components.nestquest.dao_children import AdminUsersDao -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): @@ -193,165 +190,6 @@ def test_seed_rejects_malformed_candidates(tmp_path) -> None: # --------------------------------------------------------------------------- -# Setup wiring: async_setup_entry seeds automatically -# --------------------------------------------------------------------------- - - -async def _setup_entry(hass, entry, registry): - from tests.test_lifecycle import _wire - - entry = _wire(entry, registry) - await async_setup_entry(hass, entry) - return entry - - -async def test_setup_seeds_allowlist_from_entry_context( - hass, make_entry -) -> None: - entry = await _setup_entry( - hass, - make_entry(context={"user_id": "ha-owner"}), - hass.registry, - ) - database = entry.runtime_data.database - try: - assert await list_admin_ids(database) == ["ha-owner"] - finally: - await database.close() - assert await async_unload_entry(hass, entry) is True - - -async def test_setup_seeds_allowlist_from_stored_entry_data( - hass, make_entry -) -> None: - """A lost database re-seeded from the entry's stored admin copy.""" - from custom_components.nestquest.const import CONF_ADMIN_USER_IDS - - entry = await _setup_entry( - hass, - make_entry(data={CONF_ADMIN_USER_IDS: ["user-a", "user-b"]}), - hass.registry, - ) - database = entry.runtime_data.database - try: - assert await list_admin_ids(database) == ["user-a", "user-b"] - finally: - await database.close() - assert await async_unload_entry(hass, entry) is True - - -async def test_setup_without_context_leaves_allowlist_empty( - hass, make_entry -) -> None: - """Fail closed: no context user and no stored copy means an empty - allowlist — nobody is admin until the owner configures it.""" - entry = await _setup_entry(hass, make_entry(), hass.registry) - database = entry.runtime_data.database - try: - assert await list_admin_ids(database) == [] - finally: - await database.close() - assert await async_unload_entry(hass, entry) is True - - -async def test_setup_does_not_reseed_narrowed_allowlist( - hass, make_entry -) -> None: - from custom_components.nestquest import async_unload_entry - from custom_components.nestquest.const import CONF_ADMIN_USER_IDS - - entry = await _setup_entry( - hass, make_entry(context={"user_id": "ha-owner"}), hass.registry - ) - database = entry.runtime_data.database - await set_admin_ids(database, ["user-parent"]) - await database.close() - assert await async_unload_entry(hass, entry) is True - - # Reload with the SAME context: the narrowed list survives. - entry_reloaded = await _setup_entry( - hass, make_entry(context={"user_id": "ha-owner"}), hass.registry - ) - database_b = entry_reloaded.runtime_data.database - try: - assert await list_admin_ids(database_b) == ["user-parent"] - finally: - await database.close() - assert await async_unload_entry(hass, entry_reloaded) is True - - -async def test_setup_seed_prefers_options_over_entry_data( - hass, make_entry -) -> None: - """Seed precedence on a fresh database: the options flow's saved - list (the current one) beats the config flow's original copy.""" - from custom_components.nestquest.const import CONF_ADMIN_USER_IDS - - entry = await _setup_entry( - hass, - make_entry( - options={CONF_ADMIN_USER_IDS: ["user-current"]}, - data={CONF_ADMIN_USER_IDS: ["ha-owner"]}, - context={"user_id": "ha-owner"}, - ), - hass.registry, - ) - database = entry.runtime_data.database - try: - assert await list_admin_ids(database) == ["user-current"] - finally: - await database.close() - assert await async_unload_entry(hass, entry) is True - - -async def test_setup_owner_fallback_seeds_owner_accounts( - hass, make_entry -) -> None: - """Last-resort seed: with no persisted copy and no flow-context - user, HA owner accounts are seeded so first setup is never - ownerless (the real flow may lose the context; owners can already - do everything in HA).""" - from unittest.mock import AsyncMock - - hass.auth.async_get_users = AsyncMock( - return_value=[ - SimpleNamespace(id="owner-1", is_owner=True), - SimpleNamespace(id="member-1", is_owner=False), - SimpleNamespace(id="owner-2", is_owner=True), - ] - ) - entry = await _setup_entry(hass, make_entry(), hass.registry) - database = entry.runtime_data.database - try: - assert sorted(await list_admin_ids(database)) == ["owner-1", "owner-2"] - finally: - await database.close() - assert await async_unload_entry(hass, entry) is True - - -async def test_setup_owner_fallback_not_used_when_context_present( - hass, make_entry -) -> None: - """The context user (flow caller) wins over the owner fallback: - a non-owner parent configuring the integration becomes the admin.""" - from unittest.mock import AsyncMock - - hass.auth.async_get_users = AsyncMock( - return_value=[SimpleNamespace(id="owner-1", is_owner=True)] - ) - entry = await _setup_entry( - hass, - make_entry(context={"user_id": "parent-configured"}), - hass.registry, - ) - database = entry.runtime_data.database - try: - assert await list_admin_ids(database) == ["parent-configured"] - finally: - await database.close() - assert await async_unload_entry(hass, entry) is True - - def test_seed_owner_ids_argument(tmp_path) -> None: async def _body(database): # Malformed owner lists are rejected like any other candidate — @@ -377,54 +215,13 @@ def test_seed_owner_ids_argument(tmp_path) -> None: _with_db(tmp_path, "seed-owner-arg.db")(_body) -async def test_reopened_setup_failure_closes_database( - hass, make_entry, monkeypatch -) -> None: - """A seeding failure on the reopened-record path closes the just- - opened connection before re-raising (no leaked handles across HA's - setup retries).""" - import pytest as pytest_module - from unittest.mock import AsyncMock - - import custom_components.nestquest as nestquest_module - from custom_components.nestquest.db import NestQuestDatabase - - # First: a successful setup to create the runtime record and file. - entry = await _setup_entry(hass, make_entry(), hass.registry) - await async_unload_entry(hass, entry) - - # Now a re-setup whose seeding fails after the reopen. - async def _failing_seed(database, **kwargs): - raise RuntimeError("seed boom") - - monkeypatch.setattr( - nestquest_module, "seed_setup_admin", _failing_seed, raising=True - ) - - close_calls: list[bool] = [] - original_close = NestQuestDatabase.close - - async def _spy_close(self): - close_calls.append(True) - return await original_close(self) - - monkeypatch.setattr(NestQuestDatabase, "close", _spy_close) - - with pytest_module.raises(RuntimeError, match="seed boom"): - await _setup_entry(hass, entry, hass.registry) - # The reopen path opened AND closed a connection; the failure is - # the last thing observed. - assert close_calls[-1] is True - - -# --------------------------------------------------------------------------- # is_admin: the fail-closed resolver (Feature 09's verdict function) # --------------------------------------------------------------------------- def test_is_admin_verdict_matrix(tmp_path) -> None: async def _body(database): - from custom_components.nestquest.admin_allowlist import is_admin + from custom_components.nestquest.core.admin_allowlist import is_admin await set_admin_ids(database, ["user-owner"]) assert await is_admin(database, "user-owner") is True @@ -447,7 +244,7 @@ def test_is_admin_verdict_matrix(tmp_path) -> None: def test_is_admin_empty_allowlist_fails_closed(tmp_path) -> None: """An empty allowlist returns False for EVERYONE, not True.""" async def _body(database): - from custom_components.nestquest.admin_allowlist import is_admin + from custom_components.nestquest.core.admin_allowlist import is_admin for candidate in ("user-owner", "", None, 1): assert await is_admin(database, candidate) is False @@ -460,7 +257,7 @@ def test_is_admin_false_after_last_admin_removal_attempt(tmp_path) -> None: """The refusal keeps the verdict sound: a refused removal leaves the admin in place, so is_admin still resolves True.""" async def _body(database): - from custom_components.nestquest.admin_allowlist import is_admin + from custom_components.nestquest.core.admin_allowlist import is_admin await set_admin_ids(database, ["user-owner"]) with pytest.raises(ValueError, match="at least one admin"): diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index a4a31e2..d7d7b80 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -1,50 +1,85 @@ -"""Tests for the per-child binary sensors (Feature 10, task 4).""" +"""Tests for the per-child binary sensors (Feature 10, task 4). + +The snapshot comes from the API service's panel route (the presence +engine resolves server-side), so these tests script the route's +payload through :class:`conftest.StubSnapshotClient` instead of +seeding a local database. +""" from __future__ import annotations -import datetime - -from conftest import wire_entry_to_registry +from conftest import StubSnapshotClient, set_coordinator_client, wire_entry_to_registry from custom_components.nestquest import async_setup_entry -from custom_components.nestquest.children import create_child, list_children from custom_components.nestquest.const import DOMAIN -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.recurrence import ScheduleRule -from custom_components.nestquest.completion import complete_instance - -SEED_HORIZON_DAYS = 3 def _binary(hass, child_id: int, kind: str): return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"] -async def _setup_entry(hass, make_entry): - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - return entry +def _child( + child_id: int, + name: str, + *, + present=True, + next_present=None, + instances=(), +): + completed = sum(1 for i in instances if i["state"] == "completed") + due = len(instances) + return { + "child_id": child_id, + "child_name": name, + "present": present, + "next_present": next_present, + "due_today": due, + "completed_today": completed, + "remaining_today": due - completed, + "completion_pct": 100 if due == 0 else round(completed / due * 100), + "instances": list(instances), + } -async def _seed_two_children(database) -> tuple: - ada = await create_child(database, "Ada") - bo = await create_child(database, "Bo") - today = datetime.date.today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} +def _instance(instance_id: int, child_id: int, *, state="open") -> dict: + return { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": "morning", + "due_time": "08:00", + "state": state, + "overdue": False, + "completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00", + "on_time": None if state == "open" else True, + } + + +def _payload(children) -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": 0, + "children": list(children), + } + + +ADA = 1 +BO = 2 +CORY = 3 + + +async def _setup_entry(hass, make_entry, *payloads): + """Set up the integration with a scripted snapshot client.""" + entry = wire_entry_to_registry( + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} ), - [ada.id, bo.id], - ["morning"], + hass.registry, ) - end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - return ada, bo + set_coordinator_client(entry, StubSnapshotClient(*payloads)) + assert await async_setup_entry(hass, entry) is True + return entry, entry.runtime_data.coordinator async def test_all_done_off_with_open_quests_and_zero_quests( @@ -52,88 +87,76 @@ async def test_all_done_off_with_open_quests_and_zero_quests( ) -> None: """all_done is OFF while quests are owed AND on a zero-quest day; a child with no quests never fires the celebration automation.""" - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await create_child(database, "Cory") # zero quests - await coordinator.async_refresh() + _entry, coordinator = await _setup_entry( + hass, + make_entry, + _payload( + [ + _child(ADA, "Ada", instances=[_instance(7, ADA)]), + _child(BO, "Bo", instances=[_instance(8, BO)]), + _child(CORY, "Cory"), + ] + ), + ) - assert _binary(hass, ada.id, "all_done").is_on is False - cory = [ - child for child in coordinator.data.children - if child.child_name == "Cory" - ][0] - assert _binary(hass, cory.child_id, "all_done").is_on is False + assert _binary(hass, ADA, "all_done").is_on is False + assert _binary(hass, BO, "all_done").is_on is False + assert _binary(hass, CORY, "all_done").is_on is False async def test_all_done_on_only_when_day_clear(hass, make_entry) -> None: - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await coordinator.async_refresh() - snapshot_ada = [ - child for child in coordinator.data.children - if child.child_id == ada.id - ][0] - instance_id = snapshot_ada.instances[0].instance_id - await complete_instance( - database, - instance_id, - actor_source="panel", - actor_child_id=ada.id, + _entry, coordinator = await _setup_entry( + hass, + make_entry, + _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]), + _payload( + [ + _child( + ADA, + "Ada", + instances=[_instance(7, ADA, state="completed")], + ) + ] + ), ) await coordinator.async_refresh() - assert _binary(hass, ada.id, "all_done").is_on is True + assert _binary(hass, ADA, "all_done").is_on is True async def test_present_today_on_without_schedule_off_when_absent( hass, make_entry ) -> None: - """No schedule means present every day; an all-absent pattern - today reports OFF.""" - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await create_child(database, "Cory") - today = datetime.date.today() - await coordinator.async_refresh() - cory = [ - child for child in coordinator.data.children - if child.child_name == "Cory" - ][0] - await PresenceSchedulesDao(database).upsert_by_child( - cory.child_id, 1, today.isoformat(), "" + """Presence is resolved by the API's presence engine: present=True + renders ON, present=False renders OFF.""" + _entry, _coordinator = await _setup_entry( + hass, + make_entry, + _payload([_child(ADA, "Ada"), _child(CORY, "Cory", present=False)]), ) - await coordinator.async_refresh() - assert _binary(hass, ada.id, "present_today").is_on is True - assert _binary(hass, cory.child_id, "present_today").is_on is False + assert _binary(hass, ADA, "present_today").is_on is True + assert _binary(hass, CORY, "present_today").is_on is False async def test_binary_sensor_identity_and_attributes(hass, make_entry) -> None: - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await coordinator.async_refresh() + _entry, _coordinator = await _setup_entry( + hass, make_entry, _payload([_child(ADA, "Ada")]) + ) - all_done = _binary(hass, ada.id, "all_done") - present = _binary(hass, ada.id, "present_today") - assert all_done.unique_id == f"nestquest_child_{ada.id}_all_done" - assert present.unique_id == f"nestquest_child_{ada.id}_present_today" + all_done = _binary(hass, ADA, "all_done") + present = _binary(hass, ADA, "present_today") + assert all_done.unique_id == f"nestquest_child_{ADA}_all_done" + assert present.unique_id == f"nestquest_child_{ADA}_present_today" assert all_done.name == "NestQuest Ada all done" assert present.name == "NestQuest Ada present today" for entity in (all_done, present): assert entity.device_info.name == "NestQuest Ada" assert all_done.extra_state_attributes == { - "child_id": ada.id, + "child_id": ADA, "child_name": "Ada", } assert present.extra_state_attributes == { - "child_id": ada.id, + "child_id": ADA, "child_name": "Ada", "next_present": None, } @@ -145,40 +168,28 @@ async def test_present_today_carries_next_present_when_away( """A child away today carries the ISO date of their next present day (the panel's away plate renders ``Returns , `` from it); an always-absent pattern carries ``None``.""" - entry = await _setup_entry(hass, make_entry) - coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, _bo = await _seed_two_children(database) - await create_child(database, "Cory") - today = datetime.date.today() - pattern = ",".join( - str(day) for day in range(7) if day != today.weekday() + _entry, _coordinator = await _setup_entry( + hass, + make_entry, + _payload( + [ + _child( + ADA, + "Ada", + present=False, + next_present="2026-09-24", + ), + _child(CORY, "Cory", present=False, next_present=None), + ] + ), ) - await PresenceSchedulesDao(database).upsert_by_child( - ada.id, 1, today.isoformat(), pattern - ) - await PresenceSchedulesDao(database).upsert_by_child( - await _cory_id(database), 1, today.isoformat(), "" - ) - await coordinator.async_refresh() - assert _binary(hass, ada.id, "present_today").is_on is False - assert _binary(hass, ada.id, "present_today").extra_state_attributes[ + assert _binary(hass, ADA, "present_today").is_on is False + assert _binary(hass, ADA, "present_today").extra_state_attributes[ "next_present" - ] == (today + datetime.timedelta(days=1)).isoformat() + ] == "2026-09-24" - cory = [ - child for child in coordinator.data.children - if child.child_name == "Cory" - ][0] - assert _binary(hass, cory.child_id, "present_today").is_on is False - assert _binary(hass, cory.child_id, "present_today").extra_state_attributes[ + assert _binary(hass, CORY, "present_today").is_on is False + assert _binary(hass, CORY, "present_today").extra_state_attributes[ "next_present" ] is None - - -async def _cory_id(database) -> int: - for child in await list_children(database): - if child.display_name == "Cory": - return child.id - raise AssertionError("Cory was not created") diff --git a/tests/test_children_registry.py b/tests/test_children_registry.py index 6ad47b5..cd48747 100644 --- a/tests/test_children_registry.py +++ b/tests/test_children_registry.py @@ -8,16 +8,16 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from custom_components.nestquest.children import ( +from custom_components.nestquest.core.children import ( create_child, edit_child, list_children, reorder_children, set_child_active, ) -from custom_components.nestquest.dao_children import ChildRecord, ChildrenDao -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.dao_children import ChildRecord, ChildrenDao +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): @@ -739,12 +739,12 @@ def test_deactivating_child_preserves_definitions_instances_events( async def _body(database): from datetime import date, timedelta - from custom_components.nestquest.dao_children import ChildrenDao - from custom_components.nestquest.dao_instances import ( + from custom_components.nestquest.core.dao_children import ChildrenDao + from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) - from custom_components.nestquest.dao_rules import ( + from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, ScheduleRulesDao, ) @@ -831,7 +831,7 @@ def test_no_code_path_deletes_a_child_row() -> None: import inspect - from custom_components.nestquest.dao_children import ChildrenDao + from custom_components.nestquest.core.dao_children import ChildrenDao methods = { name diff --git a/tests/test_complete_quest_proxy.py b/tests/test_complete_quest_proxy.py index 3aca922..4097b23 100644 --- a/tests/test_complete_quest_proxy.py +++ b/tests/test_complete_quest_proxy.py @@ -5,117 +5,69 @@ complete route through the entry's ONE shared API client (the same instance the coordinator polls and the SSE subscription reads), with exactly the payload contract the panel cards send (``instance_id``, ``actor=panel``, ``actor_child_id``). The service performs NO local -database completion and fires NO local bus event — the API service -emits the transition on its SSE stream and the integration's -subscription re-fires it (firing it locally too would double-fire) — -and it refreshes the coordinator after a successful call so the -sensors reflect the completion immediately. Every failure (a typed -:class:`NestQuestApiError`, an unconfigured client, a non-panel -actor) surfaces as a :class:`HomeAssistantError`, token-free. +completion and fires NO local bus event — the API service emits the +transition on its SSE stream and the integration's subscription +re-fires it (firing it locally too would double-fire) — and it +refreshes the coordinator after a successful call so the sensors +reflect the completion immediately. Every failure (a typed +:class:`NestQuestApiError`, an unconfigured client, a non-panel actor) +surfaces as a :class:`HomeAssistantError`, token-free. The scripted clients here stand in for the API service: recording and failing ``complete_instance`` implementations, no network, no aiohttp. """ from __future__ import annotations -import datetime - import pytest from homeassistant.exceptions import HomeAssistantError -from conftest import ( - LazyLocalSnapshotClient, - set_coordinator_client, - wire_entry_to_registry, -) +from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry from custom_components.nestquest import async_setup_entry from custom_components.nestquest.api_client import NestQuestApiError from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, CONF_PANEL_TOKEN, DOMAIN, EVENT_CHILD_DAY_COMPLETE, EVENT_QUEST_COMPLETED, SERVICE_COMPLETE_QUEST, ) -from custom_components.nestquest.dao_instances import QuestInstancesDao -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, -) -from custom_components.nestquest.recurrence import ScheduleRule - -ADMIN_CTX = {"user_id": "admin-1"} -class _RecordingApiClient(LazyLocalSnapshotClient): +class _RecordingApiClient(ScriptedPanelClient): """Snapshot-only stand-in that RECORDS completion calls. - ``complete_instance`` deliberately performs NO database write: the + ``complete_instance`` deliberately performs NO local write: the API service is remote, and the assertions below must be able to prove the service handler itself completed nothing locally. """ - def __init__(self, hass, entry_id: str) -> None: - super().__init__(hass, entry_id) - self.complete_calls: list[tuple[int, int]] = [] - - async def complete_instance( - self, instance_id: int, actor_child_id: int - ) -> dict: - self.complete_calls.append((instance_id, actor_child_id)) - return {"status": "done"} - class _FailingApiClient(_RecordingApiClient): """A stand-in whose completion fails the way the client types it.""" - def __init__(self, hass, entry_id: str, error: NestQuestApiError) -> None: - super().__init__(hass, entry_id) - self._error = error - - async def complete_instance( - self, instance_id: int, actor_child_id: int - ) -> dict: - self.complete_calls.append((instance_id, actor_child_id)) - raise self._error + def __init__(self, error: NestQuestApiError, *outcomes) -> None: + super().__init__(*outcomes) + self.fail_complete_with = error -async def _setup_seeded_entry(hass, make_entry, client): - """Wire, script, and set up an entry with one child's daily quest.""" +def _empty_snapshot() -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": 0, + "children": [], + } + + +async def _setup_entry(hass, make_entry, client): + """Wire, script, and set up an entry against the scripted client.""" entry = wire_entry_to_registry( - make_entry( - data={ - CONF_ADMIN_USER_IDS: [ADMIN_CTX["user_id"]], - CONF_PANEL_TOKEN: "secret-panel-token", - } - ), + make_entry(data={CONF_PANEL_TOKEN: "secret-panel-token"}), hass.registry, ) set_coordinator_client(entry, client) assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - from custom_components.nestquest.children import create_child - - child = await create_child(database, "Ada") - today = datetime.date.today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} - ), - [child.id], - ["morning"], - ) - end = (today + datetime.timedelta(days=3)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today.isoformat(), today.isoformat() - ) - assert instances - return entry, client, child, instances[0] + return entry def _record_refreshes(entry) -> list[bool]: @@ -133,11 +85,10 @@ async def test_complete_quest_reaches_the_shared_api_client( hass, make_entry ) -> None: """A panel tap calls ``complete_instance`` on the entry's shared - client with the tapped child, completes NOTHING locally, fires NO - local event, and refreshes the coordinator immediately.""" - entry, client, child, instance = await _setup_seeded_entry( - hass, make_entry, _RecordingApiClient(hass, "test_entry") - ) + client with the tapped child, fires NO local event, and refreshes + the coordinator immediately.""" + client = _RecordingApiClient(_empty_snapshot()) + entry = await _setup_entry(hass, make_entry, client) assert entry.runtime_data.coordinator.api_client is client, ( "the service must proxy through the entry's ONE shared client" ) @@ -147,21 +98,13 @@ async def test_complete_quest_reaches_the_shared_api_client( DOMAIN, SERVICE_COMPLETE_QUEST, { - "instance_id": instance.id, + "instance_id": 7, "actor": "panel", - "actor_child_id": child.id, + "actor_child_id": 1, }, ) - assert client.complete_calls == [(instance.id, child.id)] - # No local completion: the instance is still open in the local - # database — only the API service writes (through its own client). - from custom_components.nestquest.completion import instance_state - - assert ( - await instance_state(entry.runtime_data.database, instance.id) - == "open" - ) + assert client.complete_calls == [(7, 1)] # No local firing: the completed (and day-complete) events arrive # over the SSE stream, re-fired by the subscription — never from # this handler. @@ -198,13 +141,10 @@ async def test_api_failure_surfaces_as_a_service_error( ) -> None: """A typed API failure (non-2xx or transport) raises ``HomeAssistantError`` carrying the typed message — which never - includes the panel token — with nothing completed or fired - locally, and no refresh for a mutation that never happened.""" - entry, client, child, instance = await _setup_seeded_entry( - hass, - make_entry, - _FailingApiClient(hass, "test_entry", error), - ) + includes the panel token — with nothing fired locally, and no + refresh for a mutation that never happened.""" + client = _FailingApiClient(error, _empty_snapshot()) + entry = await _setup_entry(hass, make_entry, client) refreshes = _record_refreshes(entry) with pytest.raises(HomeAssistantError, match=fragment) as excinfo: @@ -212,22 +152,16 @@ async def test_api_failure_surfaces_as_a_service_error( DOMAIN, SERVICE_COMPLETE_QUEST, { - "instance_id": instance.id, + "instance_id": 7, "actor": "panel", - "actor_child_id": child.id, + "actor_child_id": 1, }, ) # The typed error is chained, and the service error carries the # typed message only — never the panel token. assert excinfo.value.__cause__ is error assert "secret-panel-token" not in str(excinfo.value) - assert client.complete_calls == [(instance.id, child.id)] - from custom_components.nestquest.completion import instance_state - - assert ( - await instance_state(entry.runtime_data.database, instance.id) - == "open" - ) + assert client.complete_calls == [(7, 1)] assert hass.bus.fired(EVENT_QUEST_COMPLETED) == [] assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] assert refreshes == [] @@ -241,9 +175,7 @@ async def test_unconfigured_api_client_surfaces_as_a_service_error( of reaching an attribute error.""" from custom_components.nestquest.coordinator import _UnconfiguredApiClient - entry, _client, child, instance = await _setup_seeded_entry( - hass, make_entry, _UnconfiguredApiClient() - ) + await _setup_entry(hass, make_entry, _UnconfiguredApiClient()) with pytest.raises( HomeAssistantError, match="NestQuest API is not configured" @@ -252,9 +184,9 @@ async def test_unconfigured_api_client_surfaces_as_a_service_error( DOMAIN, SERVICE_COMPLETE_QUEST, { - "instance_id": instance.id, + "instance_id": 7, "actor": "panel", - "actor_child_id": child.id, + "actor_child_id": 1, }, ) @@ -265,19 +197,18 @@ async def test_non_panel_actor_is_rejected_before_the_client( """The API's panel route completes as the tapped child only: a user-actor call fails fast with a clear error and never reaches the client.""" - entry, client, child, instance = await _setup_seeded_entry( - hass, make_entry, _RecordingApiClient(hass, "test_entry") - ) + client = _RecordingApiClient(_empty_snapshot()) + await _setup_entry(hass, make_entry, client) with pytest.raises(HomeAssistantError, match="actor must be 'panel'"): await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, { - "instance_id": instance.id, + "instance_id": 7, "actor": "user", - "actor_user_id": ADMIN_CTX["user_id"], + "actor_user_id": "admin-1", }, - context=ADMIN_CTX, + context={"user_id": "admin-1"}, ) assert client.complete_calls == [] diff --git a/tests/test_completion.py b/tests/test_completion.py index 2b3cd02..d8b82a0 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,27 +8,27 @@ import re import pytest -from custom_components.nestquest.completion import ( +from custom_components.nestquest.core.completion import ( append_event, complete_instance, instance_state, list_missed_for_child, uncomplete_instance, ) -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_instances import ( +from custom_components.nestquest.core.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_instances import ( EVENT_COMPLETED, EVENT_UNCOMPLETED, CompletionEventRecord, CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, ScheduleRulesDao, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): @@ -311,7 +311,7 @@ def test_now_pins_occurred_at_utc(tmp_path) -> None: def test_module_never_exposes_update_or_delete() -> None: - import custom_components.nestquest.completion as completion + import custom_components.nestquest.core.completion as completion public = { name diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index a6ecc2d..d40aff7 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -156,23 +156,13 @@ def test_no_hardcoded_domain_or_db_filename_in_new_modules() -> None: f"Found hard-coded database filename in {py_file.name}:{node.lineno}" ) -async def test_flow_persists_flow_user_as_initial_admin(hass, make_flow) -> None: - """The HA user completing the flow lands in entry data as the - initial admin copy: entry.context is not persisted by real HA, so - the durable record must carry the owner.""" - from custom_components.nestquest.const import CONF_ADMIN_USER_IDS - - flow = make_flow(hass) - flow.context = {"user_id": "ha-owner"} - result = await flow.async_step_user({}) - assert result["type"] == "create_entry" - assert result["data"] == {CONF_ADMIN_USER_IDS: ["ha-owner"]} - - async def test_flow_without_context_user_keeps_data_untouched( hass, make_flow ) -> None: + """A submit with user input stores it as-is; no admin copy is + invented (the allowlist lives in the API service's own store since + the DB removal).""" flow = make_flow(hass) - result = await flow.async_step_user({"horizon_days": 7}) + result = await flow.async_step_user({"update_interval": 60}) assert result["type"] == "create_entry" - assert result["data"] == {"horizon_days": 7} + assert result["data"] == {"update_interval": 60} diff --git a/tests/test_core_settings.py b/tests/test_core_settings.py index 67a4b9c..a9f04b9 100644 --- a/tests/test_core_settings.py +++ b/tests/test_core_settings.py @@ -14,9 +14,9 @@ Covers the contracts from task 4dd8f870 plus the review fixes: test feeds the scanner each bypass shape and asserts it flags them. - A settings object constructed with NON-default ``horizon_days`` and ``day_rollover_time`` drives a materialization whose resulting window - matches the non-default horizon — via BOTH the core ``materialize`` - path and the INTEGRATION path (``_run_horizon_materialization`` + - ``_register_day_rollover_listener``). + matches the non-default horizon — via the core ``materialize`` path + (the integration path it used to share is gone with the integration's + database). """ from __future__ import annotations @@ -29,7 +29,6 @@ from typing import Any import pytest import custom_components.nestquest.core as _core_mod -from conftest import freeze_nestquest_clock from custom_components.nestquest.const import ( CONF_AFTERNOON_REMINDER_ENABLED, @@ -741,7 +740,7 @@ def test_settings_module_has_no_ha_imports() -> None: # --------------------------------------------------------------------------- -# Materialization over a non-default horizon: core path + integration path. +# Materialization over a non-default horizon (core path). # --------------------------------------------------------------------------- @@ -828,85 +827,3 @@ async def test_materialize_over_non_default_horizon(hass, tmp_path) -> None: ) == [] finally: await database.close() - - -async def test_integration_path_threads_settings( - hass, tmp_path, monkeypatch -) -> None: - """The integration path threads ``settings`` through horizon_window + rollover. - - ``_run_horizon_materialization`` calls - ``settings.horizon_window(today)`` (so the helper is real and shared, - not recomputed inline), and ``_register_day_rollover_listener`` - registers at ``settings.day_rollover_hour_minute``. This runs the - INTEGRATION path with ``horizon_days=3`` and - ``day_rollover_time='03:30'`` and asserts the resulting instance - window matches [today, today+3] AND the registered hour/minute are 3/30. - """ - import custom_components.nestquest as nestquest - from custom_components.nestquest.core.dao_children import ChildrenDao - from custom_components.nestquest.core.dao_instances import ( - QuestInstancesDao, - ) - from custom_components.nestquest.core.quest_definitions import ( - create_quest_definition, - ) - from custom_components.nestquest.core.recurrence import ( - RuleType, - ScheduleRule, - ) - - # Freeze the integration clock so "today" is deterministic (the - # hass fixture's config.time_zone is UTC, so 2026-03-01 12:00 UTC - # localizes to 2026-03-01). - freeze_nestquest_clock( - monkeypatch, datetime.datetime(2026, 3, 1, 12, 0, tzinfo=datetime.timezone.utc) - ) - - database = await _prepare(tmp_path / "nq.db", hass.async_add_executor_job) - try: - settings = NestQuestSettings(horizon_days=3, day_rollover_time="03:30") - today = datetime.date(2026, 3, 1) - - NOW = "2026-03-01T12:00:00+00:00" - child = await ChildrenDao(database).create("Ada", NOW) - await create_quest_definition( - database, - "Daily chore", - ScheduleRule( - rule_type=RuleType.DAILY, start_date=today.isoformat() - ), - [child.id], - ["morning"], - ) - - # Integration path: the window comes from settings.horizon_window. - await nestquest._run_horizon_materialization(hass, database, settings) - - end = today + datetime.timedelta(days=3) - records = await QuestInstancesDao(database).list_by_date_range( - child.id, today.isoformat(), end.isoformat() - ) - dates = sorted({r.due_date for r in records}) - assert dates == [ - (today + datetime.timedelta(days=i)).isoformat() - for i in range(4) - ] - beyond = (end + datetime.timedelta(days=1)).isoformat() - far = (end + datetime.timedelta(days=10)).isoformat() - assert await QuestInstancesDao(database).list_by_date_range( - child.id, beyond, far - ) == [] - - # The rollover listener registers at the configured hour/minute. - remove = nestquest._register_day_rollover_listener( - hass, database, settings - ) - try: - reg = hass.time_change.registrations[-1] - assert reg["hour"] == 3 - assert reg["minute"] == 30 - finally: - remove() - finally: - await database.close() diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py index f704bc6..541c4f6 100644 --- a/tests/test_core_snapshot.py +++ b/tests/test_core_snapshot.py @@ -10,17 +10,22 @@ child's ``next_present``, a due-timed quest's ``overdue`` under a pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and ``missed`` omission) are all exercised as literals. + +The database is a standalone core one (the integration owns no +database since Feature 18's DB removal); the closing round-trip pins +that the integration's coordinator reconstructs the SAME dataclasses +from the route's payload. """ from __future__ import annotations import datetime from zoneinfo import ZoneInfo -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 -from custom_components.nestquest.children import create_child -from custom_components.nestquest.completion import complete_instance +from custom_components.nestquest.core.children import create_child +from custom_components.nestquest.core.completion import complete_instance from custom_components.nestquest.core.snapshot import ( ChildDaySnapshot, NestQuestSnapshot, @@ -28,14 +33,17 @@ from custom_components.nestquest.core.snapshot import ( build_snapshot, instance_payload, ) -from custom_components.nestquest.dao_instances import QuestInstancesDao -from custom_components.nestquest.dao_presence import ( +from custom_components.nestquest.core.dao_instances import QuestInstancesDao +from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, PresenceSchedulesDao, ) -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import create_quest_definition -from custom_components.nestquest.recurrence import ScheduleRule +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.materialize import materialize +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.quest_definitions import create_quest_definition +from custom_components.nestquest.core.recurrence import ScheduleRule +from custom_components.nestquest.core.settings import NestQuestSettings async def test_instance_payload_shape_and_missed_omission() -> None: @@ -105,7 +113,7 @@ async def test_instance_payload_shape_and_missed_omission() -> None: ) -async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None: +async def test_build_snapshot_explicit_values(hass, make_entry, tmp_path) -> None: """The builder produces the documented shape as literals, pinned in time. Ada is AWAY today (a 2-week schedule whose absent week 0 covers today, @@ -117,11 +125,12 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> scheduled active child is Ada, whose 2-week cycle anchored one day before today puts ``cycle_day`` at 2. """ - 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 - settings = entry.runtime_data.settings + # A standalone core database (the integration owns none): the + # builder and its seed run against it through the executor. + database = NestQuestDatabase(hass.async_add_executor_job) + await database.open(tmp_path / "snapshot.db") + await apply_migrations(database) + settings = NestQuestSettings() # Single clock read for the seed/snapshot anchor (no midnight skew); # the coordinator no longer owns a clock read (the API serves the @@ -303,8 +312,6 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> # 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 { @@ -328,6 +335,12 @@ async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> ], } + entry = wire_entry_to_registry( + make_entry(data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}), + hass.registry, + ) set_coordinator_client(entry, _PinnedPayloadClient()) - await coordinator.async_refresh() + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator assert coordinator.data == built + await database.close() diff --git a/tests/test_dao_children.py b/tests/test_dao_children.py index 0426169..a7097d0 100644 --- a/tests/test_dao_children.py +++ b/tests/test_dao_children.py @@ -6,15 +6,15 @@ import sqlite3 import pytest -from custom_components.nestquest.dao_children import ( +from custom_components.nestquest.core.dao_children import ( AdminUserRecord, AdminUsersDao, ChildRecord, ChildrenDao, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.schema import SCHEMA_V1_STATEMENTS -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.schema import SCHEMA_V1_STATEMENTS +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): @@ -504,9 +504,6 @@ def test_children_and_admin_sql_lives_only_in_dao_module() -> None: "tests/test_dao_rules.py", # rules/definitions guard, own scope "tests/test_dao_presence.py", # presence guard, own scope "tests/test_dao_instances.py", # instances guard, own scope - "tests/test_startup_db.py", # row-count probe of the children - # table only (proving a fresh setup loads); guard below covers - # everything else } sql_pattern = re.compile( r"(FROM|INTO|UPDATE|DELETE\s+FROM|JOIN)\s+" @@ -558,32 +555,6 @@ def _strip_function_spans(text: str, names: set[str]) -> str: for number, line in enumerate(lines, start=1) if number not in excluded ) - - -def test_startup_db_test_file_uses_dao_elsewhere() -> None: - """Compensating self-scan for the startup-test exemption: outside - the single sanctioned COUNT(*) probe, test_startup_db.py must go - through the DAO for children/admin_users data access. - """ - import re - from pathlib import Path - - startup_test = Path(__file__).parent / "test_startup_db.py" - stripped = _strip_function_spans( - startup_test.read_text(), - {"test_setup_with_valid_file_preserves_rows"}, - ) - sql_pattern = re.compile( - r"(SELECT\s[^\"']*?FROM|INSERT\s+INTO|UPDATE|DELETE\s+FROM|" - r"FROM|JOIN)\s+[`'\"]*(\[)?(children|admin_users)\b", - re.IGNORECASE, - ) - assert sql_pattern.search(stripped) is None, ( - "test_startup_db.py must go through the DAO for these tables " - "outside its sanctioned row-count probe" - ) - - def test_dao_matrix_test_file_raw_probes_are_only_constraint_probes() -> None: """Compensating self-scan for the matrix-file exemption, structurally exact: parse the two sanctioned functions' ASTs and require that diff --git a/tests/test_dao_instances.py b/tests/test_dao_instances.py index 1aef2bf..c30714b 100644 --- a/tests/test_dao_instances.py +++ b/tests/test_dao_instances.py @@ -7,16 +7,16 @@ import sqlite3 import pytest -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_instances import ( +from custom_components.nestquest.core.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_instances import ( CompletionEventRecord, CompletionEventsDao, QuestInstanceRecord, QuestInstancesDao, ) -from custom_components.nestquest.dao_rules import ScheduleRulesDao, QuestDefinitionsDao -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.dao_rules import ScheduleRulesDao, QuestDefinitionsDao +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): diff --git a/tests/test_dao_matrix.py b/tests/test_dao_matrix.py index 945471c..59961f5 100644 --- a/tests/test_dao_matrix.py +++ b/tests/test_dao_matrix.py @@ -21,24 +21,24 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from custom_components.nestquest.dao_children import ( +from custom_components.nestquest.core.dao_children import ( AdminUsersDao, ChildrenDao, ) -from custom_components.nestquest.dao_instances import ( +from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.dao_presence import ( +from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, PresenceSchedulesDao, ) -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_rules import ( ScheduleRulesDao, QuestDefinitionsDao, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): diff --git a/tests/test_dao_presence.py b/tests/test_dao_presence.py index 1b96116..8d4cbf6 100644 --- a/tests/test_dao_presence.py +++ b/tests/test_dao_presence.py @@ -6,15 +6,15 @@ import sqlite3 import pytest -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_presence import ( +from custom_components.nestquest.core.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_presence import ( PresenceOverrideRecord, PresenceOverridesDao, PresenceScheduleRecord, PresenceSchedulesDao, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations def _run(coro): @@ -741,12 +741,12 @@ def test_override_create_racing_conflicts_serialize(tmp_path) -> None: from unittest.mock import MagicMock async def _main(): - from custom_components.nestquest.dao_children import ChildrenDao - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_children import ChildrenDao + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import apply_migrations + from custom_components.nestquest.core.db import NestQuestDatabase + from custom_components.nestquest.core.migrations import apply_migrations armed = {"active": False, "gated": False} gate_open = asyncio_module.Event() diff --git a/tests/test_dao_rules.py b/tests/test_dao_rules.py index ad8bb7a..7047d86 100644 --- a/tests/test_dao_rules.py +++ b/tests/test_dao_rules.py @@ -6,8 +6,8 @@ import sqlite3 import pytest -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_rules import ( ScheduleRuleRecord, ScheduleRuleStorage, ScheduleRulesDao, @@ -17,9 +17,9 @@ from custom_components.nestquest.dao_rules import ( schedule_rule_from_storage, schedule_rule_to_storage, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, @@ -341,7 +341,7 @@ def test_rule_delete_concurrent_with_definition_create_is_safe( # delete task has been scheduled, so the create holds # the connection lock while the delete queues behind # it — then the create proceeds and wins. - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_validate = ( dao_rules.QuestDefinitionsDao._validate_rule_exists diff --git a/tests/test_db.py b/tests/test_db.py index c31ceb4..79b3e76 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -14,7 +14,7 @@ import pytest from conftest import executor_for, make_hass import custom_components.nestquest.core.db as db_module -from custom_components.nestquest.db import NestQuestDatabase +from custom_components.nestquest.core.db import NestQuestDatabase def _run(coro): @@ -933,68 +933,3 @@ def test_execute_many_exposes_rowcount(tmp_path) -> None: # --------------------------------------------------------------------------- -# Lifecycle wiring: opened once per entry, closed on unload -# --------------------------------------------------------------------------- - - -async def test_setup_entry_opens_database_in_runtime_data(hass, make_entry) -> None: - from conftest import wire_entry_to_registry - - from custom_components.nestquest import async_setup_entry, async_unload_entry - - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - runtime = entry.runtime_data - assert runtime.database is not None - assert runtime.database.connected is True - await async_unload_entry(hass, entry) - - -async def test_unload_entry_closes_database(hass, make_entry) -> None: - from conftest import wire_entry_to_registry - - from custom_components.nestquest import async_setup_entry, async_unload_entry - - entry = wire_entry_to_registry(make_entry(), hass.registry) - await async_setup_entry(hass, entry) - database = entry.runtime_data.database - assert database.connected is True - assert await async_unload_entry(hass, entry) is True - assert database.connected is False - with pytest.raises(RuntimeError, match="closed"): - await database.fetch_one("SELECT 1") - - -async def test_second_setup_does_not_open_second_connection(hass, make_entry) -> None: - from conftest import wire_entry_to_registry - - from custom_components.nestquest import async_setup_entry, async_unload_entry - - entry = wire_entry_to_registry(make_entry(), hass.registry) - await async_setup_entry(hass, entry) - first = entry.runtime_data.database - - await async_setup_entry(hass, entry) - second = entry.runtime_data.database - - assert first is second - assert first.connected is True - await async_unload_entry(hass, entry) - - -async def test_setup_unload_cycles_reopen_fresh_connection(hass, make_entry) -> None: - from conftest import wire_entry_to_registry - - from custom_components.nestquest import async_setup_entry, async_unload_entry - - entry = wire_entry_to_registry(make_entry(), hass.registry) - seen: list[NestQuestDatabase] = [] - for _ in range(3): - await async_setup_entry(hass, entry) - runtime = entry.runtime_data - assert runtime.database.connected is True - seen.append(runtime.database) - await async_unload_entry(hass, entry) - assert runtime.database.connected is False - assert len(set(seen)) == 3 - assert hass.data == {} \ No newline at end of file diff --git a/tests/test_entity_lifecycle.py b/tests/test_entity_lifecycle.py index 48f9b84..4dd384f 100644 --- a/tests/test_entity_lifecycle.py +++ b/tests/test_entity_lifecycle.py @@ -1,24 +1,16 @@ -"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8).""" +"""Entity lifecycle across child add/deactivate/reactivate (Feature 10, task 8). + +The household state the entities mirror comes from the API service's +panel route, so a deactivate/reactivate/add is scripted as a payload +change served by :class:`conftest.StubSnapshotClient`; the reload +(unload + setup) re-polls it, exactly as production does. +""" from __future__ import annotations -import datetime - -from conftest import wire_entry_to_registry +from conftest import StubSnapshotClient, 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.const import DOMAIN -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, -) -from custom_components.nestquest.recurrence import ScheduleRule -SEED_HORIZON_DAYS = 3 PER_CHILD_KINDS = ( "quests_due_today", "quests_completed_today", @@ -34,6 +26,78 @@ HOUSEHOLD_UNIQUE_IDS = ( "nestquest_cycle_day", ) +ADA = 1 +BO = 2 +CORY_ID = 4 + + +def _child(child_id: int, name: str, *, present=True, instances=()): + completed = sum(1 for i in instances if i["state"] == "completed") + due = len(instances) + return { + "child_id": child_id, + "child_name": name, + "present": present, + "next_present": None, + "due_today": due, + "completed_today": completed, + "remaining_today": due - completed, + "completion_pct": 100 if due == 0 else round(completed / due * 100), + "instances": list(instances), + } + + +def _instance(instance_id: int, child_id: int, *, state="open") -> dict: + return { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": "morning", + "due_time": "08:00", + "state": state, + "overdue": False, + "completed_at": None if state == "open" else "2026-09-23T07:55:00+00:00", + "on_time": None if state == "open" else True, + } + + +def _payload(children) -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": 0, + "children": list(children), + } + + +#: The full household (Ada + Bo, one daily quest each) and the states +#: the lifecycle walks through: Bo deactivated, reactivated, completed. +_BOTH = _payload( + [ + _child(ADA, "Ada", instances=[_instance(7, ADA)]), + _child(BO, "Bo", instances=[_instance(8, BO)]), + ] +) +_BO_GONE = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]) +_BO_DONE = _payload( + [ + _child(ADA, "Ada", instances=[_instance(7, ADA)]), + _child(BO, "Bo", instances=[_instance(8, BO, state="completed")]), + ] +) +_ADA_ONLY = _payload([_child(ADA, "Ada", instances=[_instance(7, ADA)])]) +_ADA_AND_CORY = _payload( + [ + _child(ADA, "Ada", instances=[_instance(7, ADA)]), + _child(CORY_ID, "Cory"), + ] +) + + +def _child_unique_ids(child_id: int) -> set[str]: + return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS} + async def _reload(hass, entry) -> None: """Unload then set up: the harness's restart/reload equivalent.""" @@ -41,46 +105,36 @@ async def _reload(hass, entry) -> None: assert await async_setup_entry(hass, entry) is True -def _child_unique_ids(child_id: int) -> set[str]: - return {f"nestquest_child_{child_id}_{kind}" for kind in PER_CHILD_KINDS} - - -async def _setup_and_seed(hass, make_entry): - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - ada = await create_child(database, "Ada") - bo = await create_child(database, "Bo") - today = datetime.date.today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} +async def _setup_with_household(hass, make_entry, *payloads): + """Set up the entry with a scripted, ordered snapshot history.""" + entry = wire_entry_to_registry( + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} ), - [ada.id, bo.id], - ["morning"], + hass.registry, ) - end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - await entry.runtime_data.coordinator.async_refresh() - return entry, ada, bo + client = set_coordinator_client(entry, StubSnapshotClient(*payloads)) + assert await async_setup_entry(hass, entry) is True + return entry, entry.runtime_data.coordinator, client async def test_deactivated_child_entities_removed_on_reload( hass, make_entry ) -> None: - entry, ada, bo = await _setup_and_seed(hass, make_entry) - assert _child_unique_ids(ada.id) <= set(hass.entities) - assert _child_unique_ids(bo.id) <= set(hass.entities) + """A child absent from the API's snapshot loses its entities on + reload; the surviving child's entities are untouched.""" + entry, _coordinator, _client = await _setup_with_household( + hass, make_entry, _BOTH, _BO_GONE + ) + assert _child_unique_ids(ADA) <= set(hass.entities) + assert _child_unique_ids(BO) <= set(hass.entities) - await set_child_active(entry.runtime_data.database, bo.id, False) await _reload(hass, entry) - assert _child_unique_ids(ada.id) <= set(hass.entities), ( + assert _child_unique_ids(ADA) <= set(hass.entities), ( "the surviving child's entities must be untouched" ) - assert not (_child_unique_ids(bo.id) & set(hass.entities)), ( + assert not (_child_unique_ids(BO) & set(hass.entities)), ( "the deactivated child's entities must be gone after reload" ) @@ -88,100 +142,61 @@ async def test_deactivated_child_entities_removed_on_reload( async def test_reactivation_restores_entities_with_original_unique_ids( hass, make_entry ) -> None: - entry, ada, bo = await _setup_and_seed(hass, make_entry) - original = _child_unique_ids(bo.id) - await set_child_active(entry.runtime_data.database, bo.id, False) + original = _child_unique_ids(BO) + entry, _coordinator, _client = await _setup_with_household( + hass, make_entry, _BOTH, _BO_GONE, _BOTH + ) await _reload(hass, entry) assert not (original & set(hass.entities)) - await set_child_active(entry.runtime_data.database, bo.id, True) await _reload(hass, entry) assert original <= set(hass.entities), ( "reactivation must restore the exact original unique_ids" ) - bo_due = hass.entities[f"nestquest_child_{bo.id}_quests_due_today"] + bo_due = hass.entities[f"nestquest_child_{BO}_quests_due_today"] assert bo_due.native_value == 1, "restored entities carry live state" async def test_new_child_gets_entities_without_disturbing_existing( hass, make_entry ) -> None: - entry, ada, bo = await _setup_and_seed(hass, make_entry) - before_ada = set(hass.entities) & _child_unique_ids(ada.id) + """A child ADDED to the API's snapshot gains its entities on + reload; the existing children's entities are untouched.""" + entry, _coordinator, _client = await _setup_with_household( + hass, make_entry, _ADA_ONLY, _ADA_AND_CORY + ) + before_ada = set(hass.entities) & _child_unique_ids(ADA) - cory = await create_child(entry.runtime_data.database, "Cory") await _reload(hass, entry) assert before_ada <= set(hass.entities), "existing entities untouched" - assert _child_unique_ids(cory.id) <= set(hass.entities), ( + assert _child_unique_ids(CORY_ID) <= set(hass.entities), ( "the new child's entities appear after reload" ) - assert hass.entities[f"nestquest_child_{cory.id}_quests_due_today"].native_value == 0 + assert hass.entities[ + f"nestquest_child_{CORY_ID}_quests_due_today" + ].native_value == 0 async def test_restart_equivalent_restores_all_entity_states( hass, make_entry ) -> None: """Unload + setup (the restart equivalent) restores every active - child's entities with correct states and the household rollups.""" - from custom_components.nestquest.completion import complete_instance - - entry, ada, bo = await _setup_and_seed(hass, make_entry) - coordinator = entry.runtime_data.coordinator - snapshot_ada = next( - child - for child in coordinator.data.children - if child.child_id == ada.id + child's entities with the API's current states and the household + rollups.""" + entry, _coordinator, _client = await _setup_with_household( + hass, make_entry, _BOTH, _BO_DONE ) - await complete_instance( - entry.runtime_data.database, - snapshot_ada.instances[0].instance_id, - actor_source="panel", - actor_child_id=ada.id, - ) - await coordinator.async_refresh() - assert hass.entities[ - f"nestquest_child_{ada.id}_quests_completed_today" - ].native_value == 1 - await _reload(hass, entry) assert hass.entities[ - f"nestquest_child_{ada.id}_quests_completed_today" - ].native_value == 1, "completed count survives the restart" - assert hass.entities[ - f"nestquest_child_{bo.id}_quests_completed_today" + f"nestquest_child_{ADA}_quests_completed_today" ].native_value == 0 + assert hass.entities[ + f"nestquest_child_{BO}_quests_completed_today" + ].native_value == 1, "the API's recorded completion survives the restart" assert set(HOUSEHOLD_UNIQUE_IDS) <= set(hass.entities) assert hass.entities[ "nestquest_household_quests_completed_today" ].native_value == 1 - - -async def test_unload_shuts_coordinator_before_closing_database( - hass, make_entry -) -> None: - """The coordinator's listeners are cleared while the database is - still connected: a scheduled refresh must never race the close.""" - 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 - seen_connected = [] - - async def _probe_shutdown(): - seen_connected.append(database.connected) - - original_close = database.close - - async def _recording_close(): - seen_connected.append("closing") - await original_close() - - coordinator.async_shutdown = _probe_shutdown - database.close = _recording_close - assert await async_unload_entry(hass, entry) is True - assert seen_connected == [True, "closing"], ( - "coordinator shutdown must precede the database close" - ) diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py index f135bb6..dcd9497 100644 --- a/tests/test_entity_matrix.py +++ b/tests/test_entity_matrix.py @@ -2,74 +2,156 @@ One fixture drives every assertion the done-condition names: correct values for every sensor and binary sensor (including the zero-quest -child and the 255-character state limit), the three events firing -exactly once with schema-valid payloads, the instances payload -omitting missed instances while admin_instances includes them, and no -entities for an inactive child. The per-task test files cover each -behavior in depth; this file is the single pass that proves them all -against ONE fixture, so a regression in any contract fails here too. +child and the 255-character state limit), the completed/uncompleted/ +day-complete events re-fired exactly once from the API's SSE frames, +the panel payload omitting missed instances while admin_instances +equals it, and no entities for a child the API does not report. The +per-task test files cover each behavior in depth; this file is the +single pass that proves them all against ONE fixture, so a regression +in any contract fails here too. + +Since the DB removal the household state and every transition come +from the API service, so the fixture scripts the panel route's +payloads and completion surface (:class:`conftest.ScriptedPanelClient`) +and delivers the transition frames through the SSE subscription — the +production event path. """ from __future__ import annotations -import datetime import re -from conftest import refire_api_transitions, wire_entry_to_registry +from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry from custom_components.nestquest import async_setup_entry -from custom_components.nestquest.children import ( - create_child, - list_children, - set_child_active, -) -from custom_components.nestquest.completion import derive_state, uncomplete_instance from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, DOMAIN, EVENT_CHILD_DAY_COMPLETE, EVENT_QUEST_COMPLETED, EVENT_QUEST_UNCOMPLETED, SERVICE_COMPLETE_QUEST, ) -from custom_components.nestquest.core.events import ( - build_quest_uncompleted_events, -) -from custom_components.nestquest.dao_instances import QuestInstancesDao -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, -) -from custom_components.nestquest.recurrence import ScheduleRule _TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") +ADA = 1 +BO = 2 +CORY = 3 + +_OCCURRED = "2026-09-23T07:55:00+00:00" + + +def _instance(instance_id: int, child_id: int, *, window="morning", state="open"): + return { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": window, + "due_time": "08:00" if window == "morning" else "19:00", + "state": state, + "overdue": False, + "completed_at": None if state == "open" else _OCCURRED, + "on_time": None if state == "open" else True, + } + + +def _child(child_id: int, name: str, instances=()): + completed = sum(1 for i in instances if i["state"] == "completed") + due = len(instances) + return { + "child_id": child_id, + "child_name": name, + "present": True, + "next_present": None, + "due_today": due, + "completed_today": completed, + "remaining_today": due - completed, + "completion_pct": 100 if due == 0 else round(completed / due * 100), + "instances": list(instances), + } + + +def _payload(children, cycle_day=0) -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": cycle_day, + "children": list(children), + } + + +def _completed_frame(instance_id: int, child_id: int, child_name: str, window: str): + """The completed-event frame the API service publishes for one + panel completion (the day-complete rule is the API's; its payload + shape is pinned by tests/test_api_sse.py on the production + plane).""" + return ( + EVENT_QUEST_COMPLETED, + { + "child_id": child_id, + "child_name": child_name, + "instance_id": instance_id, + "quest_title": "Brush teeth", + "window": window, + "due_date": "2026-09-23", + "due_time": "08:00" if window == "morning" else "19:00", + "occurred_at": _OCCURRED, + "was_on_time": True, + }, + ) + + +def _day_complete_frame(child_id: int, child_name: str): + """The day-complete frame the API publishes once a child's day + clears (after the LAST completion of the day).""" + return ( + EVENT_CHILD_DAY_COMPLETE, + { + "child_id": child_id, + "child_name": child_name, + "quests_due": 2, + "quests_completed": 2, + "occurred_at": _OCCURRED, + }, + ) + async def _fixture(hass, make_entry): - """Two quest children, one zero-quest child, one inactive child.""" + """Two quest children, one zero-quest child; the inactive child is + simply ABSENT from the API's snapshot (the API only reports active + children, so the integration can never create its entities).""" entry = wire_entry_to_registry( - make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry - ) - assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - ada = await create_child(database, "Ada") - bo = await create_child(database, "Bo") - cory = await create_child(database, "Cory") - ghost = await create_child(database, "Ghost") - await set_child_active(database, ghost.id, False) - today = datetime.date.today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} ), - [ada.id, bo.id], - ["morning", "evening"], + hass.registry, ) - end = (today + datetime.timedelta(days=3)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - await entry.runtime_data.coordinator.async_refresh() - return entry, (ada, bo, cory, ghost) + client = ScriptedPanelClient( + _payload( + [ + _child(ADA, "Ada", [_instance(7, ADA), _instance(8, ADA, window="evening")]), + _child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]), + _child(CORY, "Cory"), + ] + ), + _payload( + [ + _child( + ADA, + "Ada", + [ + _instance(7, ADA, state="completed"), + _instance(8, ADA, window="evening", state="completed"), + ], + ), + _child(BO, "Bo", [_instance(9, BO), _instance(10, BO, window="evening")]), + _child(CORY, "Cory"), + ] + ), + ) + set_coordinator_client(entry, client) + assert await async_setup_entry(hass, entry) is True + return entry, client def _sensor(hass, child_id, kind): @@ -77,11 +159,11 @@ def _sensor(hass, child_id, kind): async def test_entity_and_event_matrix(hass, make_entry) -> None: - entry, (ada, bo, cory, ghost) = await _fixture(hass, make_entry) + entry, client = await _fixture(hass, make_entry) coordinator = entry.runtime_data.coordinator - # --- Every active child has the full entity set; the inactive - # child has none. + # --- Every reported child has the full entity set; the child the + # API does not report has none. kinds = ( "quests_due_today", "quests_completed_today", @@ -91,36 +173,37 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: "all_done", "present_today", ) - for child in (ada, bo, cory): + ghost_id = 99 + for child in (ADA, BO, CORY): for kind in kinds: - assert f"{DOMAIN}_child_{child.id}_{kind}" in hass.entities, kind + assert f"{DOMAIN}_child_{child}_{kind}" in hass.entities, kind for kind in kinds: - assert f"{DOMAIN}_child_{ghost.id}_{kind}" not in hass.entities, ( - "inactive children must have no entities" + assert f"{DOMAIN}_child_{ghost_id}_{kind}" not in hass.entities, ( + "children absent from the API snapshot must have no entities" ) # --- Correct values against the fixture: Ada and Bo owe two # quests (morning + evening), Cory owes none and is still 100%. - for child in (ada, bo): - assert _sensor(hass, child.id, "quests_due_today").native_value == 2 + for child in (ADA, BO): + assert _sensor(hass, child, "quests_due_today").native_value == 2 assert ( - _sensor(hass, child.id, "quests_completed_today").native_value == 0 + _sensor(hass, child, "quests_completed_today").native_value == 0 ) assert ( - _sensor(hass, child.id, "quests_remaining_today").native_value == 2 + _sensor(hass, child, "quests_remaining_today").native_value == 2 ) assert ( - _sensor(hass, child.id, "completion_pct_today").native_value == 0 + _sensor(hass, child, "completion_pct_today").native_value == 0 ) - assert _sensor(hass, child.id, "next_quest").native_value == ( + assert _sensor(hass, child, "next_quest").native_value == ( "Brush teeth" ) - assert _sensor(hass, child.id, "all_done").is_on is False - assert _sensor(hass, child.id, "present_today").is_on is True - assert _sensor(hass, cory.id, "quests_due_today").native_value == 0 - assert _sensor(hass, cory.id, "completion_pct_today").native_value == 100 - assert _sensor(hass, cory.id, "all_done").is_on is False - assert _sensor(hass, cory.id, "next_quest").native_value == "none" + assert _sensor(hass, child, "all_done").is_on is False + assert _sensor(hass, child, "present_today").is_on is True + assert _sensor(hass, CORY, "quests_due_today").native_value == 0 + assert _sensor(hass, CORY, "completion_pct_today").native_value == 100 + assert _sensor(hass, CORY, "all_done").is_on is False + assert _sensor(hass, CORY, "next_quest").native_value == "none" # --- Household rollups equal the per-child sums. assert hass.entities[ @@ -140,27 +223,31 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: ), unique_id # --- Completing both of Ada's quests fires the events exactly - # once each and flips her entities immediately. - instances = await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range( - ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat() + # once each and flips her entities immediately. The transition + # events arrive over the API's SSE stream (the subscription + # re-fires them), never from the service handler. + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": 7, "actor": "panel", "actor_child_id": ADA}, ) - for instance in instances: - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance.id, - "actor": "panel", - "actor_child_id": ada.id, - }, - ) - # The transition events arrive over the API's SSE stream (the - # subscription re-fires them), never from the service handler. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": 8, "actor": "panel", "actor_child_id": ADA}, ) + client.published_frames.extend( + [ + _completed_frame(7, ADA, "Ada", "morning"), + _completed_frame(8, ADA, "Ada", "evening"), + # The API publishes the day-complete frame only when the + # day clears — after the LAST completion of the day. + _day_complete_frame(ADA, "Ada"), + ] + ) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2 for payload in hass.bus.fired(EVENT_QUEST_COMPLETED): assert _TIMESTAMP.match(payload["occurred_at"]) @@ -169,71 +256,53 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: assert payload["quest_title"] == "Brush teeth" assert isinstance(payload["instance_id"], int) assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 - assert _sensor(hass, ada.id, "all_done").is_on is True - assert _sensor(hass, ada.id, "quests_remaining_today").native_value == 0 + # The next scripted snapshot reflects both completions. + await coordinator.async_refresh() + assert _sensor(hass, ADA, "all_done").is_on is True + assert _sensor(hass, ADA, "quests_remaining_today").native_value == 0 assert ( - _sensor(hass, ada.id, "completion_pct_today").native_value == 100 + _sensor(hass, ADA, "completion_pct_today").native_value == 100 ) - # --- Reversing one quest fires the uncompleted event exactly once - # and restores the owed counts. The HA ``uncomplete_quest`` - # service is gone (the API service's admin route is the reversal - # path): drive the core completion layer it calls, publish the - # uncompleted transition the way the API does, and deliver it - # through the SSE subscription. - reversal = await uncomplete_instance( - entry.runtime_data.database, - instances[0].id, - actor_source="user", - actor_user_id="admin-1", - ) - assert reversal.appended is True - client = coordinator.api_client - client.published_frames.extend( - await build_quest_uncompleted_events( - entry.runtime_data.database, instances[0].id + # --- Reversing one quest fires the uncompleted event exactly once. + # The HA ``uncomplete_quest`` service is gone (the API service's + # admin route is the reversal path), so the reversal is the API's: + # it publishes the uncompleted frame and the integration re-fires + # it; the coordinator's next poll picks the restored counts up. + client.published_frames.append( + ( + EVENT_QUEST_UNCOMPLETED, + { + "child_id": ADA, + "child_name": "Ada", + "instance_id": 7, + "quest_title": "Brush teeth", + "window": "morning", + "due_date": "2026-09-23", + "due_time": "08:00", + "occurred_at": _OCCURRED, + }, ) ) await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 - # The reversal no longer carries an immediate local refresh: the - # coordinator's next poll picks the restored counts up. + + # --- The panel payload omits missed instances (D-009) — the API + # panel route omits missed rows ENTIRELY, so admin_instances + # cannot carry them either (the admin surface for missed quests is + # the PWA, reading the API directly). A snapshot built from the + # payload can only ever hold open and completed rows: pin that + # the payload round-trips verbatim. await coordinator.async_refresh() - 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 (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(): - import custom_components.nestquest.core.snapshot as snapshot_module - - real_derive = snapshot_module.derive_state - - def _derive(instance, latest, today): - return real_derive(instance, latest, today + datetime.timedelta(days=1)) - - snapshot_module.derive_state = _derive - try: - return await original_refresh() - finally: - snapshot_module.derive_state = real_derive - - coordinator._async_update_data = _future_dated_refresh - await coordinator.async_refresh() - attributes = _sensor(hass, bo.id, "quests_due_today").extra_state_attributes - assert attributes["instances"] == [], "panel payload omits missed" - assert attributes["admin_instances"] == [], ( + attributes = _sensor(hass, BO, "quests_due_today").extra_state_attributes + assert attributes["instances"] == [ + dict(_instance(9, BO)), + dict(_instance(10, BO, window="evening")), + ], "the panel payload round-trips the API's open rows verbatim" + assert attributes["admin_instances"] == attributes["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() async def test_payload_schema_matches_the_documented_contract( @@ -241,25 +310,16 @@ async def test_payload_schema_matches_the_documented_contract( ) -> None: """Every fired payload carries the documented fields with the documented types.""" - entry, (ada, _bo, _cory, _ghost) = await _fixture(hass, make_entry) - instances = await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range( - ada.id, datetime.date.today().isoformat(), datetime.date.today().isoformat() - ) + entry, client = await _fixture(hass, make_entry) await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - { - "instance_id": instances[0].id, - "actor": "panel", - "actor_child_id": ada.id, - }, - ) - # The completed event arrives over the API's SSE stream. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client + {"instance_id": 7, "actor": "panel", "actor_child_id": ADA}, ) + client.published_frames.append(_completed_frame(7, ADA, "Ada", "morning")) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0] for key, check in ( ("child_id", lambda v: isinstance(v, int)), diff --git a/tests/test_events.py b/tests/test_events.py index 160bd07..ac45112 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1,31 +1,25 @@ """Tests for the NestQuest bus events (Feature 10, task 6). -Since Feature 18 the integration is a client of the NestQuest API -service: ``nestquest.complete_quest`` proxies to the API's panel -complete route and fires NO local bus event — the API service builds -and publishes the transition frames on its SSE stream, and the -integration's subscription (:mod:`custom_components.nestquest.sse`) -re-fires them. The service-driven tests below therefore deliver the -frames through :func:`conftest.refire_api_transitions` (the -production event path, driven synchronously) before asserting the -bus; the shim-level tests drive :mod:`custom_components.nestquest -.events` directly, the way its remaining callers do. The HA -``uncomplete_quest`` service is also gone (the API service's admin -uncomplete route is the reversal path), so the reversal tests drive -the core completion layer the route calls and deliver the published -frame through the same SSE subscription helper. +Since Feature 18 — and, since the DB removal, without any local +business layer — the integration fires NO transition event itself: +``nestquest.complete_quest`` proxies to the API's panel complete +route, the API service builds and publishes the transition frames on +its SSE stream, and the integration's subscription +(:mod:`custom_components.nestquest.sse`) re-fires them. The tests +below script the API's payloads and frames with +:class:`conftest.ScriptedPanelClient` and deliver them through +:func:`conftest.refire_api_transitions` (the production event path, +driven synchronously) before asserting the bus. The API's own frame +construction is pinned on the production plane by tests/test_api_sse.py. """ from __future__ import annotations import re -from conftest import refire_api_transitions, wire_entry_to_registry +from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry from custom_components.nestquest import async_setup_entry -from custom_components.nestquest.children import create_child -from custom_components.nestquest.completion import uncomplete_instance from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, DOMAIN, EVENT_CHILD_DAY_COMPLETE, EVENT_QUEST_COMPLETED, @@ -33,75 +27,129 @@ from custom_components.nestquest.const import ( EVENT_QUEST_UNCOMPLETED, SERVICE_COMPLETE_QUEST, ) -from custom_components.nestquest.core.events import ( - build_quest_uncompleted_events, -) -from custom_components.nestquest.dao_instances import QuestInstancesDao -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, -) -from custom_components.nestquest.recurrence import ScheduleRule _TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") +ADA = 1 +_OCCURRED = "2026-09-23T07:55:00+00:00" -async def _setup_and_seed(hass, make_entry, *, windows=("morning",), seed_date=None): - """Seed a household and materialize today's instances. - ``seed_date`` pins the date instances are materialized for (and the - daily rule's ``start_date``); it defaults to the host's - ``datetime.date.today()``. Callers that need to test the day-complete - rule against a non-UTC HA-local timezone pass the local zone's - ``today`` here so the seeded instance's ``due_date`` matches it. - """ - import datetime +def _instance(instance_id: int, child_id: int, *, window="morning", state="open"): + return { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": window, + "due_time": "08:00" if window == "morning" else "19:00", + "state": state, + "overdue": False, + "completed_at": None if state == "open" else _OCCURRED, + "on_time": None if state == "open" else True, + } - if seed_date is None: - seed_date = datetime.date.today() + +def _child(child_id: int, name: str, instances=()): + completed = sum(1 for i in instances if i["state"] == "completed") + due = len(instances) + return { + "child_id": child_id, + "child_name": name, + "present": True, + "next_present": None, + "due_today": due, + "completed_today": completed, + "remaining_today": due - completed, + "completion_pct": 100 if due == 0 else round(completed / due * 100), + "instances": list(instances), + } + + +def _payload(children) -> dict: + return { + "today_iso": "2026-09-23", + "cycle_day": 0, + "children": list(children), + } + + +def _open_payload(instance_count: int) -> dict: + return _payload( + [ + _child( + ADA, + "Ada", + [_instance(10 + offset, ADA) for offset in range(instance_count)], + ) + ] + ) + + +def _completed_frame(instance_id: int, *, window="morning"): + return ( + EVENT_QUEST_COMPLETED, + { + "child_id": ADA, + "child_name": "Ada", + "instance_id": instance_id, + "quest_title": "Brush teeth", + "window": window, + "due_date": "2026-09-23", + "due_time": "08:00" if window == "morning" else "19:00", + "occurred_at": _OCCURRED, + "was_on_time": True, + }, + ) + + +def _day_complete_frame(quests_due: int): + return ( + EVENT_CHILD_DAY_COMPLETE, + { + "child_id": ADA, + "child_name": "Ada", + "quests_due": quests_due, + "quests_completed": quests_due, + "occurred_at": _OCCURRED, + }, + ) + + +async def _setup_entry(hass, make_entry, client): + """Wire, script, and set up an entry serving the scripted household.""" entry = wire_entry_to_registry( - make_entry(data={CONF_ADMIN_USER_IDS: ["admin-1"]}), hass.registry - ) - assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - child = await create_child(database, "Ada") - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": seed_date.isoformat()} + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} ), - [child.id], - list(windows), + hass.registry, ) - today_iso = seed_date.isoformat() - end_iso = (seed_date + datetime.timedelta(days=3)).isoformat() - await materialize(database, today_iso, end_iso, today=seed_date) - instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today_iso, today_iso - ) - return entry, child, instances + set_coordinator_client(entry, client) + assert await async_setup_entry(hass, entry) is True + return entry async def test_quest_completed_fires_with_documented_payload( hass, make_entry ) -> None: - entry, child, instances = await _setup_and_seed(hass, make_entry) - instance_id = instances[0].id + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) # The completed event arrives over the API's SSE stream (the # subscription re-fires it), never from the service handler. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client - ) + client.published_frames.append(_completed_frame(instance_id)) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) fired = hass.bus.fired(EVENT_QUEST_COMPLETED) assert len(fired) == 1 payload = fired[0] - assert payload["child_id"] == child.id + assert payload["child_id"] == ADA assert payload["child_name"] == "Ada" assert payload["instance_id"] == instance_id assert payload["quest_title"] == "Brush teeth" @@ -111,26 +159,25 @@ async def test_quest_completed_fires_with_documented_payload( async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None: - entry, child, instances = await _setup_and_seed(hass, make_entry) - instance_id = instances[0].id + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) # The no-op verdict is decided in the API's completion layer: the # second call appended nothing, so the API published no second # frame and the bus stays at exactly one event. - client = entry.runtime_data.coordinator.api_client - assert [result.appended for _id, result in client.completions] == [ - True, - False, - ] + client.published_frames.append(_completed_frame(instance_id)) + from conftest import refire_api_transitions + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 @@ -138,92 +185,101 @@ async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None: async def test_child_day_complete_fires_when_day_clears( hass, make_entry ) -> None: - entry, child, instances = await _setup_and_seed(hass, make_entry) - for instance in instances: - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance.id, - "actor": "panel", - "actor_child_id": child.id, - }, - ) - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 + await hass.services.call( + DOMAIN, + SERVICE_COMPLETE_QUEST, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) + client.published_frames.extend( + [ + _completed_frame(instance_id), + _day_complete_frame(quests_due=1), + ] + ) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) assert len(day_complete) == 1, "exactly one day-complete per cleared day" payload = day_complete[0] - assert payload["child_id"] == child.id + assert payload["child_id"] == ADA assert payload["child_name"] == "Ada" - assert payload["quests_due"] == len(instances) + assert payload["quests_due"] == 1 assert _TIMESTAMP.match(payload["occurred_at"]) async def test_child_day_complete_does_not_fire_partway( hass, make_entry ) -> None: - entry, child, instances = await _setup_and_seed( - hass, make_entry, windows=("morning", "evening") - ) - assert len(instances) == 2 + client = ScriptedPanelClient(_open_payload(2)) + entry = await _setup_entry(hass, make_entry, client) await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - { - "instance_id": instances[0].id, - "actor": "panel", - "actor_child_id": child.id, - }, + {"instance_id": 10, "actor": "panel", "actor_child_id": ADA}, ) # The API publishes the completion but not a day-complete (the # child's day is not clear yet); the subscription re-fires only # what was published. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client - ) + client.published_frames.append(_completed_frame(10)) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == [] await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - { - "instance_id": instances[1].id, - "actor": "panel", - "actor_child_id": child.id, - }, + {"instance_id": 11, "actor": "panel", "actor_child_id": ADA}, ) - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client + client.published_frames.extend( + [ + _completed_frame(11, window="evening"), + _day_complete_frame(quests_due=2), + ] ) + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: """An actual reversal fires the uncompleted event — but only once, and only through the API service's transition stream: the HA - ``uncomplete_quest`` service is gone, so the reversal is driven - through the core completion layer the API's admin uncomplete route - calls, with the published frame delivered by the SSE subscription.""" - entry, child, instances = await _setup_and_seed(hass, make_entry) - instance_id = instances[0].id - database = entry.runtime_data.database - client = entry.runtime_data.coordinator.api_client + ``uncomplete_quest`` service is gone, so the reversal is the API's + admin route; its published frame is delivered by the SSE + subscription.""" + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - {"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id}, + {"instance_id": instance_id, "actor": "panel", "actor_child_id": ADA}, ) - # The reversal appends through the core completion layer; the API - # route publishes the transition on its SSE stream and the - # subscription re-fires it on the bus. - result = await uncomplete_instance( - database, instance_id, actor_source="user", actor_user_id="admin-1" - ) - assert result.appended is True client.published_frames.extend( - await build_quest_uncompleted_events(database, instance_id) + [ + _completed_frame(instance_id), + # The API's admin uncomplete route publishes the reversal + # the same way the panel completion publishes its event. + ( + EVENT_QUEST_UNCOMPLETED, + { + "child_id": ADA, + "child_name": "Ada", + "instance_id": instance_id, + "quest_title": "Brush teeth", + "window": "morning", + "due_date": "2026-09-23", + "due_time": "08:00", + "occurred_at": _OCCURRED, + }, + ), + ] ) + from conftest import refire_api_transitions + await refire_api_transitions(hass, entry, client) fired = hass.bus.fired(EVENT_QUEST_UNCOMPLETED) assert len(fired) == 1 @@ -233,10 +289,6 @@ async def test_uncompleted_fires_on_reversal_only(hass, make_entry) -> None: assert _TIMESTAMP.match(payload["occurred_at"]) # Un-completing an already-open instance is a no-op: nothing is # appended, nothing is published, and nothing further fires. - no_op = await uncomplete_instance( - database, instance_id, actor_source="user", actor_user_id="admin-1" - ) - assert no_op.appended is False await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_UNCOMPLETED)) == 1 @@ -249,12 +301,15 @@ def test_missed_event_name_is_documented_contract() -> None: async def test_appended_verdict_is_atomic_under_duplicate_calls( hass, make_entry ) -> None: - """Concurrent duplicate completes decide appended under the same - lock as the write: exactly one fires the transition event.""" + """Concurrent duplicate completes each reach the API, whose + completion layer decides the appended verdict: exactly one + appended, so the API published exactly one frame and the bus + carries one event.""" import asyncio - entry, child, instances = await _setup_and_seed(hass, make_entry) - instance_id = instances[0].id + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) + instance_id = 10 calls = [ hass.services.call( DOMAIN, @@ -262,139 +317,60 @@ async def test_appended_verdict_is_atomic_under_duplicate_calls( { "instance_id": instance_id, "actor": "panel", - "actor_child_id": child.id, + "actor_child_id": ADA, }, ) for _ in range(2) ] await asyncio.gather(*calls) - # The API's completion layer (the same core lock) decides the - # verdict: one call appended, the duplicate did not — so the API - # published exactly one frame and the bus carries one event. - client = entry.runtime_data.coordinator.api_client - assert len(client.completions) == 2 - assert [result.appended for _id, result in client.completions].count( - True - ) == 1 + assert len(client.complete_calls) == 2 + # The API's completion layer decides the verdict: one call + # appended, the duplicate did not — so the API published exactly + # one frame and the bus carries one event. + client.published_frames.append(_completed_frame(instance_id)) + from conftest import refire_api_transitions + await refire_api_transitions(hass, entry, client) assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1 -async def test_day_complete_ignored_for_backdated_completion( - hass, make_entry -) -> None: - """Completing an instance NOT due today never evaluates the - child's day-complete, so a cleared day is announced at most once - per day and only by that day's completions.""" - import datetime - - from custom_components.nestquest.completion import ( - complete_instance as _complete, - ) - - entry, child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - # A future instance (tomorrow) completed after today's day is - # already clear. - today = datetime.date.today() - end = (today + datetime.timedelta(days=3)).isoformat() - all_instances = await QuestInstancesDao(database).list_by_date_range( - child.id, today.isoformat(), end - ) - today_ids = {instance.id for instance in instances} - tomorrow_instance = next( - instance - for instance in all_instances - if instance.id not in today_ids - ) - for instance in instances: - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": instance.id, - "actor": "panel", - "actor_child_id": child.id, - }, - ) - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client - ) - assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 - - await _complete( - database, - tomorrow_instance.id, - actor_source="user", - actor_user_id="admin-1", - ) - await hass.services.call( - DOMAIN, - SERVICE_COMPLETE_QUEST, - { - "instance_id": tomorrow_instance.id, - "actor": "panel", - "actor_child_id": child.id, - }, - ) - # The service call on the already-done future instance is a no-op - # inside the API's completion layer: nothing appended, nothing - # published, nothing re-fired — today's single announcement stands. - client = entry.runtime_data.coordinator.api_client - assert client.completions[-1][1].appended is False - await refire_api_transitions(hass, entry, client) - assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, ( - "a back-dated/future completion must not re-announce today" - ) - # The direct business-layer call published nothing (only the API's - # completion path publishes), and the follow-up service call on - # the now-done future instance was a no-op: today's single - # completion stands. - assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances) - - async def test_completion_updates_entities_immediately( hass, make_entry ) -> None: """The remaining-today sensor reflects the completion the moment the service call returns — no manual refresh — and the day-complete event fired exactly once when the day became clear.""" - from custom_components.nestquest.const import EVENT_CHILD_DAY_COMPLETE - - entry, child, instances = await _setup_and_seed(hass, make_entry) - from conftest import wire_entry_to_registry # noqa: F401 - - coordinator = entry.runtime_data.coordinator - # Entities only exist after one refresh since they were seeded - # after setup; create them, then prove the service path refreshes. - await coordinator.async_refresh() + client = ScriptedPanelClient( + _open_payload(1), + _payload([_child(ADA, "Ada", [_instance(10, ADA, state="completed")])]), + ) + entry = await _setup_entry(hass, make_entry, client) remaining_before = hass.entities[ - f"nestquest_child_{child.id}_quests_remaining_today" + f"nestquest_child_{ADA}_quests_remaining_today" ].native_value - assert remaining_before == len(instances) + assert remaining_before == 1 await hass.services.call( DOMAIN, SERVICE_COMPLETE_QUEST, - { - "instance_id": instances[0].id, - "actor": "panel", - "actor_child_id": child.id, - }, + {"instance_id": 10, "actor": "panel", "actor_child_id": ADA}, ) remaining_after = hass.entities[ - f"nestquest_child_{child.id}_quests_remaining_today" + f"nestquest_child_{ADA}_quests_remaining_today" ].native_value - assert remaining_after == len(instances) - 1, ( + assert remaining_after == 0, ( "sensor must reflect the completion without a manual refresh" ) - # The transition events arrive over the API's SSE stream. - await refire_api_transitions( - hass, entry, entry.runtime_data.coordinator.api_client - ) - assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == ( - 1 if len(instances) == 1 else 0 + client.published_frames.extend( + [ + _completed_frame(10), + _day_complete_frame(quests_due=1), + ] ) + from conftest import refire_api_transitions + + await refire_api_transitions(hass, entry, client) + assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1 async def test_forced_refreshes_serialize_no_stale_publish( @@ -405,7 +381,8 @@ async def test_forced_refreshes_serialize_no_stale_publish( it, so the last published snapshot reflects the latest mutation.""" import asyncio - entry, child, instances = await _setup_and_seed(hass, make_entry) + client = ScriptedPanelClient(_open_payload(1)) + entry = await _setup_entry(hass, make_entry, client) coordinator = entry.runtime_data.coordinator order: list[str] = [] @@ -420,465 +397,3 @@ async def test_forced_refreshes_serialize_no_stale_publish( assert order == ["start", "end", "start", "end"], ( "overlapping refreshes must serialize: " + str(order) ) - - -# --------------------------------------------------------------------------- -# Direct core-builder unit tests (P2.2a): exact payload key sets, event -# order, and was_on_time threading. These target -# custom_components.nestquest.core.events directly, bypassing the shim, -# so a payload-shape regression is caught even if the shim masks it. -# --------------------------------------------------------------------------- - -_COMPLETED_KEYS = { - "child_id", - "child_name", - "instance_id", - "quest_title", - "window", - "due_date", - "due_time", - "occurred_at", - "was_on_time", -} -_UNCOMPLETED_KEYS = { - "child_id", - "child_name", - "instance_id", - "quest_title", - "window", - "due_date", - "due_time", - "occurred_at", -} -_DAY_COMPLETE_KEYS = { - "child_id", - "child_name", - "quests_due", - "quests_completed", - "occurred_at", -} - - -async def test_build_quest_completed_event_payload_key_set(hass, make_entry) -> None: - """The completed-event payload carries exactly the documented keys - (no extra fields, no missing field) and threads ``was_on_time``.""" - from custom_components.nestquest.core.events import build_quest_completed_event - - entry, child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - event_type, payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=True - ) - assert event_type == EVENT_QUEST_COMPLETED - assert set(payload) == _COMPLETED_KEYS, ( - f"completed payload keys drift: got {set(payload)} expected {_COMPLETED_KEYS}" - ) - assert payload["was_on_time"] is True - assert payload["child_id"] == child.id - assert payload["child_name"] == "Ada" - assert payload["instance_id"] == instances[0].id - assert payload["quest_title"] == "Brush teeth" - assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] - - -async def test_build_quest_completed_event_was_on_time_threads_all_shapes( - hass, make_entry -) -> None: - """``was_on_time`` is carried verbatim — True, False, and None - (the unknown-timing shape) all round-trip into the payload.""" - from custom_components.nestquest.core.events import build_quest_completed_event - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - for wanted in (True, False, None): - _etype, payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=wanted - ) - assert payload["was_on_time"] is wanted, ( - f"was_on_time={wanted!r} did not thread through" - ) - - -async def test_build_quest_uncompleted_events_payload_key_set( - hass, make_entry -) -> None: - """The uncompleted-event payload carries exactly the documented keys - (no ``was_on_time`` — that field is completion-only).""" - from custom_components.nestquest.core.events import ( - build_quest_uncompleted_events, - ) - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - events = await build_quest_uncompleted_events(database, instances[0].id) - assert len(events) == 1 - event_type, payload = events[0] - assert event_type == EVENT_QUEST_UNCOMPLETED - assert set(payload) == _UNCOMPLETED_KEYS, ( - f"uncompleted payload keys drift: got {set(payload)} " - f"expected {_UNCOMPLETED_KEYS}" - ) - assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] - - -async def test_build_child_day_complete_event_payload_key_set( - hass, make_entry -) -> None: - """The day-complete payload carries exactly the documented keys - when the child's whole day clears, and reuses the completed - event's ``occurred_at`` stamp (one transition, one timestamp).""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - today = datetime.date.today() - completed_type, completed_payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=True - ) - # Mark every today-instance done so the day clears. - from custom_components.nestquest.completion import complete_instance - - for instance in instances: - await complete_instance( - database, - instance.id, - actor_source="panel", - actor_child_id=_child.id, - today=today, - ) - day_event = await build_child_day_complete_event( - database, completed_payload, today=today - ) - assert day_event is not None - event_type, day_payload = day_event - assert event_type == EVENT_CHILD_DAY_COMPLETE - assert set(day_payload) == _DAY_COMPLETE_KEYS, ( - f"day-complete payload keys drift: got {set(day_payload)} " - f"expected {_DAY_COMPLETE_KEYS}" - ) - assert day_payload["occurred_at"] == completed_payload["occurred_at"], ( - "day-complete must reuse the completed event's occurred_at stamp" - ) - assert day_payload["quests_due"] == len(instances) - assert day_payload["quests_completed"] == len(instances) - - -async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> None: - """The shim fires ``nestquest_quest_completed`` BEFORE - ``nestquest_child_day_complete`` (the pre-extraction ordering), so - a failure in the day-complete DB reads cannot suppress the - completed event. Asserted by bus event ORDER, not just counts. - - Driven directly: since Feature 18 the completion service proxies - to the API (whose event ordering is tested in test_api_sse.py); - this targets the shim module itself, the way its remaining - callers invoke it. - """ - from custom_components.nestquest.completion import ( - complete_instance as _complete, - ) - from custom_components.nestquest.events import fire_quest_completed - - entry, child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - for instance in instances: - result = await _complete( - database, - instance.id, - actor_source="panel", - actor_child_id=child.id, - ) - await fire_quest_completed( - hass, - database, - instance.id, - was_on_time=result.was_on_time, - ) - ordered = [ - etype for etype, _payload in hass.bus.events - if etype in (EVENT_QUEST_COMPLETED, EVENT_CHILD_DAY_COMPLETE) - ] - # The completed event must precede the day-complete event. - first_completed = ordered.index(EVENT_QUEST_COMPLETED) - first_day = ordered.index(EVENT_CHILD_DAY_COMPLETE) - assert first_completed < first_day, ( - f"completed must fire before day-complete; order was {ordered}" - ) - - -# --------------------------------------------------------------------------- -# Day-complete rule coverage (P2.2b): an explicit ``today`` proves the -# four outcomes — zero-quest day, non-today instance, partial day, and -# fully-cleared today. -# --------------------------------------------------------------------------- - - -async def test_day_complete_zero_quest_day_yields_no_event( - hass, make_entry -) -> None: - """A zero-quest day never fires day-complete: when the completed - payload's ``due_date`` matches ``today`` but the child has NO - instances due that date, the builder returns ``None`` (the - all-done binary sensor's rule — a day with zero quests owed can - never be "cleared").""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - # A date the daily rule does NOT cover (well past the materialized - # horizon) — zero instances due this date. - zero_quest_day = datetime.date.today() + datetime.timedelta(days=365) - _etype, completed_payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=True - ) - # Override due_date to the zero-quest day so the builder evaluates - # it (otherwise the due_date != today short-circuit fires first). - crafted = dict(completed_payload, due_date=zero_quest_day.isoformat()) - result = await build_child_day_complete_event( - database, crafted, today=zero_quest_day - ) - assert result is None, ( - "a zero-quest day must not yield a day-complete event" - ) - - -async def test_day_complete_non_today_instance_yields_no_event( - hass, make_entry -) -> None: - """Completing an instance NOT due today never evaluates the - child's day-complete for today: only the completed event fires.""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - - entry, _child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - # Complete a FUTURE instance (tomorrow), pass today = today. - from custom_components.nestquest.dao_instances import QuestInstancesDao - - today = datetime.date.today() - end = (today + datetime.timedelta(days=3)).isoformat() - all_instances = await QuestInstancesDao(database).list_by_date_range( - _child.id, today.isoformat(), end - ) - today_ids = {i.id for i in instances} - tomorrow_instance = next(i for i in all_instances if i.id not in today_ids) - _etype, completed_payload = await build_quest_completed_event( - database, tomorrow_instance.id, was_on_time=True - ) - assert completed_payload["due_date"] != today.isoformat() - result = await build_child_day_complete_event( - database, completed_payload, today=today - ) - assert result is None, ( - "a non-today instance must not yield a day-complete event for today" - ) - - -async def test_day_complete_partial_day_yields_no_event( - hass, make_entry -) -> None: - """A partial day (some quests still open) yields only the - completed event — day-complete does not fire until the WHOLE day - clears.""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - from custom_components.nestquest.completion import complete_instance - - entry, child, instances = await _setup_and_seed( - hass, make_entry, windows=("morning", "evening") - ) - assert len(instances) == 2 - database = entry.runtime_data.database - today = datetime.date.today() - # Complete ONLY the morning instance — the evening one stays open. - await complete_instance( - database, - instances[0].id, - actor_source="panel", - actor_child_id=child.id, - today=today, - ) - _etype, completed_payload = await build_quest_completed_event( - database, instances[0].id, was_on_time=True - ) - result = await build_child_day_complete_event( - database, completed_payload, today=today - ) - assert result is None, ( - "a partial day (evening quest still open) must not fire day-complete" - ) - - -async def test_day_complete_fully_cleared_today_yields_event( - hass, make_entry -) -> None: - """A fully-cleared today (all owed quests done) yields the - completed event AND the child_day_complete event.""" - import datetime - - from custom_components.nestquest.core.events import ( - build_child_day_complete_event, - build_quest_completed_event, - ) - from custom_components.nestquest.completion import complete_instance - - entry, child, instances = await _setup_and_seed( - hass, make_entry, windows=("morning", "evening") - ) - assert len(instances) == 2 - database = entry.runtime_data.database - today = datetime.date.today() - for instance in instances: - await complete_instance( - database, - instance.id, - actor_source="panel", - actor_child_id=child.id, - today=today, - ) - # The last completion's payload drives the day-complete evaluation. - _etype, completed_payload = await build_quest_completed_event( - database, instances[-1].id, was_on_time=True - ) - result = await build_child_day_complete_event( - database, completed_payload, today=today - ) - assert result is not None, ( - "a fully-cleared today must yield the day-complete event" - ) - assert result[0] == EVENT_CHILD_DAY_COMPLETE - assert result[1]["quests_due"] == len(instances) - assert result[1]["quests_completed"] == len(instances) - - -# --------------------------------------------------------------------------- -# Shim timezone wiring (P2.2c): the conftest fake hass defaults -# hass.config.time_zone to UTC, so the day-complete rule's HA-local -# ``today`` wiring is otherwise untested. This test sets the zone to -# one whose current local date differs from UTC and asserts the -# day-complete rule uses THAT local date, not UTC. -# --------------------------------------------------------------------------- - - -async def test_shim_day_complete_uses_ha_local_timezone( - hass, make_entry -) -> None: - """The shim's day-complete rule reads ``hass.config.time_zone`` - and derives ``today`` in HA-local time, not UTC. - - Picks a zone whose current local date differs from UTC, seeds an - instance for THAT zone's today (not UTC's today), fires the shim - directly for a completion of that instance, and asserts - ``nestquest_child_day_complete`` fires — proving the local date - reached the day-complete rule. If no candidate zone has a - different date right now (only possible inside the ~1h window - around 12:00 UTC when all zones share one date), the test skips - rather than run vacuously. - - Driven directly: since Feature 18 the completion service proxies - to the API (whose day-complete ``today`` is the API host's local - date, tested in test_api_sse.py); this targets the shim module - itself, the way its remaining callers invoke it. - """ - import datetime as _dt - from zoneinfo import ZoneInfo - - from custom_components.nestquest.completion import ( - complete_instance as _complete, - ) - from custom_components.nestquest.events import fire_quest_completed - - utc_today = _dt.datetime.now(_dt.timezone.utc).date() - far_zone = None - far_today = None - for tz_name in ( - "Pacific/Kiritimati", # +14 (UTC+14) - "Pacific/Auckland", # +12/+13 - "Pacific/Chatham", # +12:45/+13:45 - "Pacific/Honolulu", # -10 - "America/Los_Angeles", # -8/-7 - "Etc/GMT+12", # -12 - "Etc/GMT-13", # +13 - ): - local_today = _dt.datetime.now(ZoneInfo(tz_name)).date() - if local_today != utc_today: - far_zone = tz_name - far_today = local_today - break - if far_zone is None: - import pytest - - pytest.skip( - "no candidate zone has a date different from UTC right now" - ) - - hass.config.time_zone = far_zone - entry, child, instances = await _setup_and_seed( - hass, make_entry, seed_date=far_today - ) - database = entry.runtime_data.database - for instance in instances: - result = await _complete( - database, - instance.id, - actor_source="panel", - actor_child_id=child.id, - ) - await fire_quest_completed( - hass, - database, - instance.id, - was_on_time=result.was_on_time, - ) - day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) - assert len(day_complete) == 1, ( - f"day-complete must fire under {far_zone} (local today " - f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); " - f"got {len(day_complete)} events" - ) - - -async def test_build_quest_missed_event_payload_key_set( - hass, make_entry -) -> None: - """The missed-event payload carries exactly the documented keys — - the same shape the nightly sweep hand-builds (§3), with no - ``was_on_time`` (completion-only).""" - from custom_components.nestquest.core.events import ( - build_quest_missed_event, - ) - - entry, child, instances = await _setup_and_seed(hass, make_entry) - database = entry.runtime_data.database - event_type, payload = await build_quest_missed_event( - database, instances[0].id - ) - assert event_type == EVENT_QUEST_MISSED - assert event_type == "nestquest_quest_missed" - assert set(payload) == _UNCOMPLETED_KEYS, ( - f"missed payload keys drift: got {set(payload)} " - f"expected {_UNCOMPLETED_KEYS}" - ) - assert payload["child_id"] == child.id - assert payload["child_name"] == "Ada" - assert payload["instance_id"] == instances[0].id - assert payload["quest_title"] == "Brush teeth" - assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index a0f39e7..3bfbac9 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -9,12 +9,8 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest.const import ( - CONF_DAY_ROLLOVER_TIME, - CONF_HORIZON_DAYS, - CONF_MORNING_SUMMARY_TIME, - DEFAULT_HORIZON_DAYS, - SERVICE_COMPLETE_QUEST, DOMAIN as DOMAIN_CONST, + SERVICE_COMPLETE_QUEST, ) @@ -29,15 +25,25 @@ async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None: assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data -async def test_setup_runtime_data_has_entry_id_and_settings(hass, make_entry) -> None: - entry = _wire(make_entry(options={"horizon_days": 7}), hass.registry) +async def test_setup_runtime_data_has_entry_id_and_coordinator(hass, make_entry) -> None: + entry = _wire(make_entry(), hass.registry) await async_setup_entry(hass, entry) runtime = entry.runtime_data assert runtime.entry_id == entry.entry_id - assert runtime.settings.horizon_days == 7 + assert runtime.coordinator is not None assert callable(runtime.remove_update_listener) +async def test_setup_registers_no_time_change_listener(hass, make_entry) -> None: + """The integration owns no database any more: the daily + day-rollover/materialization listener is gone (the API service + owns the horizon), so setup registers NOTHING against the + time-change listener bookkeeping.""" + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + assert hass.time_change.size == 0 + + async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None: registry = hass.registry entry = _wire(make_entry(), registry) @@ -112,7 +118,7 @@ async def test_options_update_triggers_reload_once(hass, make_entry) -> None: registry = hass.registry entry = _wire(make_entry(), registry) await async_setup_entry(hass, entry) - entry.options = {"horizon_days": 30} + entry.options = {"update_interval": 60} await registry.async_dispatch_options_update(entry) assert hass.config_entries.async_reload.call_count == 1 assert registry.reloaded == [entry.entry_id] @@ -190,6 +196,16 @@ async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None: assert set(hass.data) == {DOMAIN_CONST} +async def test_setup_registers_the_completion_service(hass, make_entry) -> None: + """The one domain-global service registers with setup and deregisters + on the last unload.""" + entry = _wire(make_entry(), hass.registry) + await async_setup_entry(hass, entry) + assert hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST) + await async_unload_entry(hass, entry) + assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST) + + async def test_setup_awaitable_listener_removal_supported() -> None: """A coroutine-returning remover is awaited during unload.""" removed = [] @@ -208,9 +224,9 @@ async def test_setup_awaitable_listener_removal_supported() -> None: assert entry.runtime_data is None -async def test_setup_closes_database_when_listener_registration_fails() -> None: - """A listener-registration failure must not leak the opened database.""" - from custom_components.nestquest.db import NestQuestDatabase +async def test_setup_unwinds_when_listener_registration_fails() -> None: + """A listener-registration failure must not leak partial setup state.""" + from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST class _Boom(Exception): pass @@ -220,13 +236,16 @@ async def test_setup_closes_database_when_listener_registration_fails() -> None: hass, _registry = make_hass() with pytest.raises(_Boom): await async_setup_entry(hass, entry) - assert hass.data.get(DOMAIN, {}) == {} + assert hass.data.get(DOMAIN_CONST, {}) == {} assert not hasattr(entry, "runtime_data") + assert not hass.services.has_service(DOMAIN_CONST, SERVICE_COMPLETE_QUEST) -async def test_unload_closes_database_even_when_listener_removal_raises() -> None: - """Unload removes the listener, closes the DB in a finally, then raises.""" - from custom_components.nestquest.db import NestQuestDatabase +async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> None: + """A raising update-listener remover must not skip the coordinator + shutdown: the retained error is re-raised only after the teardown + ran and the runtime record was cleared.""" + from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST class _RemoveBoom(Exception): pass @@ -238,383 +257,17 @@ async def test_unload_closes_database_even_when_listener_removal_raises() -> Non entry.add_update_listener = lambda listener: _remover hass, _registry = make_hass() await async_setup_entry(hass, entry) - database = entry.runtime_data.database + coordinator = entry.runtime_data.coordinator + seen = [] + + async def _shutdown(): + seen.append("shutdown") + + coordinator.async_shutdown = _shutdown + with pytest.raises(_RemoveBoom): await async_unload_entry(hass, entry) - assert database.connected is False, "database leaked despite remover error" + assert seen == ["shutdown"], "coordinator shutdown must have run" assert entry.runtime_data is None - assert DOMAIN not in hass.data - assert hass.time_change.size == 0, "time-change listener leaked" - - -async def test_unload_cancels_time_listener_even_when_update_remover_raises() -> None: - """A raising update-listener remover must not skip the time-change cancel. - - Both removers are independent: the update-listener remover raising must - still cancel the daily time-change callback, whose action would otherwise - fire after unload against a closed database. - """ - - class _RemoveBoom(Exception): - pass - - def _remover(): - raise _RemoveBoom() - - entry = make_config_entry(entry_id="update-boom-time-cancel") - entry.add_update_listener = lambda listener: _remover - hass, _registry = make_hass() - await async_setup_entry(hass, entry) - assert hass.time_change.size == 1 - with pytest.raises(_RemoveBoom): - await async_unload_entry(hass, entry) - assert hass.time_change.size == 0, "time-change listener not cancelled" - assert entry.runtime_data is None - assert DOMAIN not in hass.data - - -async def test_setup_registers_day_rollover_listener(hass, make_entry) -> None: - """Setup registers a daily rollover listener and stores its cancel fn.""" - entry = _wire(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - assert hass.time_change.size == 1 - registration = hass.time_change.registrations[0] - assert registration["hour"] == 0 - assert registration["minute"] == 0 - assert callable(entry.runtime_data.remove_time_change_listener) - - -async def test_setup_registers_day_rollover_at_configured_time(hass, make_entry) -> None: - """The listener fires at the entry option's rollover time, not the default.""" - entry = _wire(make_entry(options={CONF_DAY_ROLLOVER_TIME: "06:30"}), hass.registry) - await async_setup_entry(hass, entry) - registration = hass.time_change.registrations[0] - assert registration["hour"] == 6 - assert registration["minute"] == 30 - - -async def test_unload_cancels_day_rollover_listener(hass, make_entry) -> None: - """Unload removes the time-change listener alongside the update listener.""" - entry = _wire(make_entry(), hass.registry) - await async_setup_entry(hass, entry) - assert hass.time_change.size == 1 - assert await async_unload_entry(hass, entry) is True + assert DOMAIN_CONST not in hass.data assert hass.time_change.size == 0 - - -async def test_rollover_time_change_reregisters_listener_at_new_time( - hass, make_entry -) -> None: - """A rollover-time options change re-registers the listener at the new time.""" - entry = _wire( - make_entry(options={CONF_DAY_ROLLOVER_TIME: "00:00"}), hass.registry - ) - await async_setup_entry(hass, entry) - assert (hass.time_change.registrations[0]["hour"], hass.time_change.registrations[0]["minute"]) == (0, 0) - - # The options flow writes the new rollover time, then HA reloads the - # entry: unload tears down the old listener, setup re-registers it. - entry.options = {CONF_DAY_ROLLOVER_TIME: "06:30"} - await async_unload_entry(hass, entry) - assert hass.time_change.size == 0 - - assert await async_setup_entry(hass, entry) is True - assert hass.time_change.size == 1 - registration = hass.time_change.registrations[0] - assert (registration["hour"], registration["minute"]) == (6, 30) - - -async def test_day_rollover_listener_materializes_ha_local_horizon( - hass, make_entry, monkeypatch -) -> None: - """Firing the listener materializes today..today+horizon in HA local time.""" - import datetime - from unittest.mock import AsyncMock - from zoneinfo import ZoneInfo - - import custom_components.nestquest as nestquest - - entry = _wire(make_entry(), hass.registry) - await async_setup_entry(hass, entry) - database = entry.runtime_data.database - - fake = AsyncMock() - monkeypatch.setattr(nestquest, "_materialize_run", fake) - - time_zone = ZoneInfo(hass.config.time_zone) - today = datetime.datetime.now(time_zone).date() - expected_start = today.isoformat() - expected_end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() - - await hass.time_change.fire() - - fake.assert_awaited_once_with( - database, expected_start, expected_end, today=today - ) - - -async def test_day_rollover_uses_configured_horizon_days(hass, make_entry) -> None: - """The daily run materializes only the configured horizon window.""" - import datetime - - from custom_components.nestquest.dao_instances import QuestInstancesDao - - entry = _wire(make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry) - assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - - # A definition created AFTER setup has no instances yet (create does not - # materialize); firing the daily listener must generate only the horizon. - child_id = await _seed_daily_child_and_definition( - database, _local_today(hass) - ) - await hass.time_change.fire() - - today = _local_today(hass) - end = (today + datetime.timedelta(days=5)).isoformat() - records = await QuestInstancesDao(database).list_by_date_range( - child_id, today.isoformat(), end - ) - assert len(records) == 6 - - beyond = (today + datetime.timedelta(days=6)).isoformat() - beyond_end = ( - today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - assert await QuestInstancesDao(database).list_by_date_range( - child_id, beyond, beyond_end - ) == [] - - -def test_day_rollover_time_change_tracker_rejects_local_kwarg() -> None: - """The fake async_track_time_change mirrors HA 2024.6 (no ``local`` kwarg). - - Setup drives the local-time wrapper directly, so passing ``local=`` must - raise TypeError — otherwise a regression in the production call would be - silently masked by the fake. - """ - import inspect as _inspect - - from homeassistant.helpers.event import async_track_time_change - - params = _inspect.signature(async_track_time_change).parameters - assert list(params) == ["hass", "action", "hour", "minute", "second"] - with pytest.raises(TypeError): - async_track_time_change(None, lambda _now: None, hour=0, minute=0, local=True) - - -async def test_setup_applies_all_v1_tables(hass, make_entry) -> None: - """Setup applies the full v1 DDL: all four v1 tables exist afterwards.""" - entry = _wire(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - rows = await database.fetch_all( - "SELECT name FROM sqlite_master WHERE type = 'table' " - "AND name IN ('children', 'admin_users', 'schedule_rules', " - "'quest_definitions') ORDER BY name" - ) - assert [row[0] for row in rows] == [ - "admin_users", - "children", - "quest_definitions", - "schedule_rules", - ] - await async_unload_entry(hass, entry) - - -async def _seed_daily_child_and_definition(database, today) -> int: - """Create one always-present child + a daily definition starting today. - - Returns the child's id. No presence schedule means the child is present - every day, so the daily rule yields exactly one instance per horizon day. - """ - from custom_components.nestquest.dao_children import ChildrenDao - from custom_components.nestquest.quest_definitions import ( - create_quest_definition, - ) - from custom_components.nestquest.recurrence import RuleType, ScheduleRule - - NOW = "2026-09-14T12:00:00+00:00" - child = await ChildrenDao(database).create("Ada", NOW) - await create_quest_definition( - database, - "Daily chore", - ScheduleRule(rule_type=RuleType.DAILY, start_date=today.isoformat()), - [child.id], - ["morning"], - ) - return child.id - - -def _local_today(hass) -> "datetime.date": - import datetime - from zoneinfo import ZoneInfo - - return datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() - - -async def test_setup_backfills_instances_over_horizon(hass, make_entry) -> None: - """Setup materializes once so a restart backfills any missed days.""" - import datetime - - from custom_components.nestquest.dao_instances import QuestInstancesDao - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import apply_migrations - from custom_components.nestquest.store import async_get_db_path - - # Seed the database BEFORE setup: a restart reopens an existing file that - # already holds definitions. The startup backfill must pick them up. - db_path = await async_get_db_path(hass) - pre = NestQuestDatabase(hass.async_add_executor_job) - await pre.open(db_path) - await apply_migrations(pre) - child_id = await _seed_daily_child_and_definition(pre, _local_today(hass)) - await pre.close() - - entry = _wire(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - - today = _local_today(hass) - end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() - records = await QuestInstancesDao(entry.runtime_data.database).list_by_date_range( - child_id, today.isoformat(), end - ) - assert len(records) == DEFAULT_HORIZON_DAYS + 1 - - -async def test_setup_backfills_configured_horizon_days(hass, make_entry) -> None: - """Setup backfills only the configured (non-default) horizon window.""" - import datetime - - from custom_components.nestquest.dao_instances import QuestInstancesDao - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import apply_migrations - from custom_components.nestquest.store import async_get_db_path - - db_path = await async_get_db_path(hass) - pre = NestQuestDatabase(hass.async_add_executor_job) - await pre.open(db_path) - await apply_migrations(pre) - child_id = await _seed_daily_child_and_definition(pre, _local_today(hass)) - await pre.close() - - entry = _wire( - make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry - ) - assert await async_setup_entry(hass, entry) is True - - today = _local_today(hass) - end = (today + datetime.timedelta(days=5)).isoformat() - records = await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range(child_id, today.isoformat(), end) - assert len(records) == 6 - - beyond = (today + datetime.timedelta(days=6)).isoformat() - beyond_end = ( - today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - ).isoformat() - assert await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range(child_id, beyond, beyond_end) == [] - - -async def test_setup_succeeds_with_fully_malformed_options( - hass, make_entry -) -> None: - """A fully malformed options dict falls back per field and setup succeeds. - - ``horizon_days=True``, ``day_rollover_time='99:99'`` and - ``morning_summary_time='25:00'`` are all invalid; the integration's - ``from_options_resilient`` falls each back to its default, so setup - SUCCEEDS, the day-rollover listener registers at the default hour 0 - minute 0, and the startup backfill materializes the default horizon - window. - """ - import datetime - - from custom_components.nestquest.dao_instances import QuestInstancesDao - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import apply_migrations - from custom_components.nestquest.store import async_get_db_path - - db_path = await async_get_db_path(hass) - pre = NestQuestDatabase(hass.async_add_executor_job) - await pre.open(db_path) - await apply_migrations(pre) - child_id = await _seed_daily_child_and_definition(pre, _local_today(hass)) - await pre.close() - - entry = _wire( - make_entry( - options={ - CONF_HORIZON_DAYS: True, - CONF_DAY_ROLLOVER_TIME: "99:99", - CONF_MORNING_SUMMARY_TIME: "25:00", - } - ), - hass.registry, - ) - assert await async_setup_entry(hass, entry) is True - - # The day-rollover listener fell back to the default 00:00. - registration = hass.time_change.registrations[0] - assert registration["hour"] == 0 - assert registration["minute"] == 0 - - # The startup backfill used the default horizon window. - today = _local_today(hass) - end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() - records = await QuestInstancesDao( - entry.runtime_data.database - ).list_by_date_range(child_id, today.isoformat(), end) - assert len(records) == DEFAULT_HORIZON_DAYS + 1 - - -async def test_setup_keeps_horizon_when_sibling_option_is_bad( - hass, make_entry -) -> None: - """A malformed sibling option does not reset the configured horizon. - - ``horizon_days=5`` is valid while ``morning_summary_time='25:00'`` is - invalid; ``from_options_resilient`` falls back ONLY the bad sibling, - so the entry's resolved settings keep ``horizon_days == 5``. - """ - entry = _wire( - make_entry( - options={ - CONF_HORIZON_DAYS: 5, - CONF_MORNING_SUMMARY_TIME: "25:00", - } - ), - hass.registry, - ) - assert await async_setup_entry(hass, entry) is True - assert entry.runtime_data.settings.horizon_days == 5 - - -async def test_setup_cleans_listeners_when_materialization_raises( - hass, make_entry, monkeypatch -) -> None: - """A startup-materialization failure rolls back every registered listener. - - The daily and update listeners are installed before the startup - materialization runs; when that walk raises, the database is closed but - the remover callables must also be invoked so no listener survives - firing against a closed database. - """ - import custom_components.nestquest as nestquest - - async def _boom(*_args, **_kwargs): - raise RuntimeError("materialization failed") - - monkeypatch.setattr(nestquest, "_materialize_run", _boom) - - entry = _wire(make_entry(), hass.registry) - with pytest.raises(RuntimeError, match="materialization failed"): - await async_setup_entry(hass, entry) - - assert hass.time_change.size == 0 - assert hass.registry.size == 0 - assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST) - assert not hasattr(entry, "runtime_data") \ No newline at end of file diff --git a/tests/test_materialize.py b/tests/test_materialize.py index e774f75..e9df73a 100644 --- a/tests/test_materialize.py +++ b/tests/test_materialize.py @@ -6,29 +6,29 @@ import datetime import pytest -from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_children import ChildrenDao from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS -from custom_components.nestquest.dao_instances import ( +from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.dao_presence import PresenceSchedulesDao -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_presence import PresenceSchedulesDao +from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, schedule_rule_to_storage, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.materialize import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.materialize import ( materialize, regenerate_for_child, regenerate_for_definition, ) -from custom_components.nestquest.migrations import apply_migrations -from custom_components.nestquest.quest_definitions import ( +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.quest_definitions import ( create_quest_definition, edit_quest_definition, ) -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, ScheduleRule, occurs_on, @@ -505,7 +505,7 @@ def test_materialize_skips_deactivated_child_mid_walk(tmp_path) -> None: def test_materialize_uses_single_presence_snapshot_mid_walk(tmp_path) -> None: async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) @@ -628,7 +628,7 @@ def test_materialize_does_not_overwrite_regenerated_due_time(tmp_path) -> None: def test_materialize_honours_overrides(tmp_path) -> None: async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) @@ -758,7 +758,7 @@ def test_materialize_clamps_past_start_to_today(tmp_path) -> None: def test_materialize_skips_inactive_definitions(tmp_path) -> None: async def _body(database): - from custom_components.nestquest.quest_definitions import ( + from custom_components.nestquest.core.quest_definitions import ( set_quest_definition_active, ) @@ -906,7 +906,7 @@ def test_regenerate_for_child_rebuilds_horizon_preserves_history( tmp_path, ) -> None: async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) @@ -1091,7 +1091,7 @@ def test_regenerate_for_child_uses_single_anchor_across_midnight( def test_regenerate_for_child_is_child_scoped(tmp_path) -> None: """Regenerating one child must never touch another child's rows.""" async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) @@ -1493,10 +1493,10 @@ def test_materialize_end_to_end_six_rule_types(tmp_path, monkeypatch) -> None: monkeypatch.setattr(materialize_module, "_now_stamp", _distinct_stamp) async def _body(database): - from custom_components.nestquest.dao_presence import ( + from custom_components.nestquest.core.dao_presence import ( PresenceOverridesDao, ) - from custom_components.nestquest.presence import PresenceSchedule + from custom_components.nestquest.core.presence import PresenceSchedule children = ChildrenDao(database) schedules = PresenceSchedulesDao(database) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index b49d14a..0bfca68 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -9,16 +9,16 @@ from unittest.mock import AsyncMock import pytest -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.schema import SCHEMA_V7_META_STATE_DDL -from custom_components.nestquest.migrations import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.schema import SCHEMA_V7_META_STATE_DDL +from custom_components.nestquest.core.migrations import ( MIGRATIONS, VERSION_TABLE, VERSION_TABLE_DDL, apply_migrations, read_schema_version, ) -from custom_components.nestquest.schema import ( +from custom_components.nestquest.core.schema import ( SCHEMA_V1_STATEMENTS, SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL, ) @@ -1064,133 +1064,3 @@ def test_partial_pending_migrations_resume_from_current_version( assert "step_b" in _tables(database) finally: _run(database.close()) - - -# --------------------------------------------------------------------------- -# Startup wiring: setup runs the migration runner, not raw DDL -# --------------------------------------------------------------------------- - - -async def _setup_entry(hass, entry, registry) -> object: - from tests.test_lifecycle import _wire # reuse the established wiring - - entry = _wire(entry, registry) - from custom_components.nestquest import async_setup_entry - - await async_setup_entry(hass, entry) - return entry - - -async def test_setup_entry_migrates_fresh_database(hass, make_entry) -> None: - import sqlite3 as sqlite3_mod - - from custom_components.nestquest.migrations import read_schema_version - from custom_components.nestquest.store import async_get_db_path - - entry = await _setup_entry(hass, make_entry(), hass.registry) - database = entry.runtime_data.database - assert await read_schema_version(database) == len(MIGRATIONS) - # The v1 tables exist because the runner applied migration 1. - names = { - row[0] - for row in await database.fetch_all( - "SELECT name FROM sqlite_master WHERE type = 'table' " - "AND name NOT LIKE 'sqlite_%'" - ) - } - assert "children" in names and "completion_events" in names - # The version table exists with its singleton row stamped. - columns = await database.fetch_all(f"PRAGMA table_info({VERSION_TABLE})") - assert [(row[1], row[3]) for row in columns] == [("id", 1), ("version", 1)] - db_path = await async_get_db_path(hass) - conn = sqlite3_mod.connect(db_path) - try: - conn.execute(f"INSERT INTO {VERSION_TABLE} (id, version) VALUES (2, 1)") - violated = False - except sqlite3_mod.IntegrityError: - violated = True - finally: - conn.close() - assert violated, "singleton-row CHECK must be live in the setup-created DB" - await database.close() - - -async def test_options_reload_reopens_and_rechecks_version( - hass, make_entry -) -> None: - """A listener-triggered reload re-runs setup against the same file. - - Unload then setup is the reload lifecycle: the old connection is - closed, apply_migrations runs again on the persisted file, keeps - version and rows intact, and the reopened connection is live. - """ - from custom_components.nestquest import async_unload_entry - from custom_components.nestquest.migrations import read_schema_version - - entry = await _setup_entry(hass, make_entry(), hass.registry) - database_a = entry.runtime_data.database - await database_a.execute( - "INSERT INTO children (display_name, created_at) VALUES (?, ?)", - ("Ada", "2026-09-13T00:00:00+00:00"), - ) - assert await async_unload_entry(hass, entry) is True - assert not database_a.connected - - entry_reloaded = await _setup_entry(hass, make_entry(), hass.registry) - database_b = entry_reloaded.runtime_data.database - assert database_b is not database_a - try: - assert await read_schema_version(database_b) == len(MIGRATIONS) - row = await database_b.fetch_one("SELECT COUNT(*) FROM children") - assert row == (1,) - finally: - await database_b.close() - - -async def test_setup_entry_failure_closes_database( - hass, make_entry, monkeypatch -) -> None: - """A failing migration closes the opened connection before re-raising. - - A migration failure surfaces as ConfigEntryNotReady (HA retries - setup) with the original error preserved as __cause__. Spies on - the real wrapper instance: records whether close() ran, rather - than inferring from hass.data (a failed setup never stores a - runtime record). - """ - import pytest as pytest_module - from homeassistant.exceptions import ConfigEntryNotReady - - import custom_components.nestquest as nestquest_module - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import ( - MIGRATIONS as _MIGRATIONS, - ) - - async def _failing(database, migrations=_MIGRATIONS): - raise RuntimeError("boom") - - monkeypatch.setattr( - nestquest_module, "apply_migrations", _failing, raising=True - ) - - close_calls: list[bool] = [] - original_close = NestQuestDatabase.close - - async def _spy_close(self): - close_calls.append(True) - return await original_close(self) - - monkeypatch.setattr(NestQuestDatabase, "close", _spy_close) - - entry = make_entry() - with pytest_module.raises(ConfigEntryNotReady) as excinfo: - await _setup_entry(hass, entry, hass.registry) - assert isinstance(excinfo.value.__cause__, RuntimeError) - assert "boom" in str(excinfo.value.__cause__) - - assert close_calls == [True], ( - "failed setup must close the connection it opened" - ) - record = hass.data["nestquest"].get(entry.entry_id) - assert record is None, "failed setup must not store runtime data" \ No newline at end of file diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index b94b319..3504127 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -14,33 +14,16 @@ from conftest import make_config_entry, make_hass, options_flow_base as _options from custom_components.nestquest import config_flow from custom_components.nestquest import options_flow from custom_components.nestquest.const import ( - CONF_ADMIN_USER_IDS, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_AFTERNOON_REMINDER_TIME, CONF_API_BASE_URL, - CONF_CELEBRATION_ENABLED, - CONF_DAY_ROLLOVER_TIME, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_END_OF_DAY_REPORT_TIME, - CONF_HORIZON_DAYS, - CONF_MORNING_SUMMARY_ENABLED, - CONF_MORNING_SUMMARY_TIME, - CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, CONF_PANEL_TOKEN, CONF_SNAPSHOT_STALENESS, CONF_UPDATE_INTERVAL, DEFAULT_API_BASE_URL, - DEFAULT_AFTERNOON_REMINDER_TIME, - DEFAULT_AUTOMATION_ENABLED, - DEFAULT_END_OF_DAY_REPORT_TIME, - DEFAULT_MORNING_SUMMARY_TIME, + DEFAULT_PANEL_IDLE_TIMEOUT, DEFAULT_PANEL_TOKEN, DEFAULT_SNAPSHOT_STALENESS, DEFAULT_UPDATE_INTERVAL, - DEFAULT_DAY_ROLLOVER_TIME, - DEFAULT_HORIZON_DAYS, - DEFAULT_PANEL_IDLE_TIMEOUT, DOMAIN, ) @@ -49,27 +32,17 @@ STRINGS_PATH = PKG_DIR / "strings.json" TRANSLATIONS_EN_PATH = PKG_DIR / "translations" / "en.json" VALID_INPUT = { - CONF_HORIZON_DAYS: 7, - CONF_DAY_ROLLOVER_TIME: "03:30", CONF_PANEL_IDLE_TIMEOUT: 60, } #: What a schema-validated submit produces from a bare VALID_INPUT on a -#: fresh entry: the Feature 10 refresh interval, the Feature 11 -#: notification section, and the Feature 18 API connection fill from +#: fresh entry: the Feature 10 refresh interval, the Feature 18 +#: staleness threshold, and the Feature 18 API connection fill from #: their defaults. FULL_INPUT = { **VALID_INPUT, CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, CONF_SNAPSHOT_STALENESS: DEFAULT_SNAPSHOT_STALENESS, - CONF_NOTIFY_TARGET: "", - CONF_MORNING_SUMMARY_TIME: DEFAULT_MORNING_SUMMARY_TIME, - CONF_AFTERNOON_REMINDER_TIME: DEFAULT_AFTERNOON_REMINDER_TIME, - CONF_END_OF_DAY_REPORT_TIME: DEFAULT_END_OF_DAY_REPORT_TIME, - CONF_MORNING_SUMMARY_ENABLED: DEFAULT_AUTOMATION_ENABLED, - CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED, - CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED, - CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED, CONF_API_BASE_URL: DEFAULT_API_BASE_URL, CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN, } @@ -87,48 +60,13 @@ def _make_entry(entry_id: str = "test_entry", options: dict | None = None, data: return make_config_entry(entry_id=entry_id, options=options, data=data) -def _make_flow(entry, *, users=None, database=None) -> options_flow.NestQuestOptionsFlow: - """Create an options flow instance bound to a stubbed config entry. - - ``users`` populates a stubbed hass.auth for the admin picker; - ``database`` wires the runtime record so the flow can read and - write the allowlist like real HA's options flow does. - """ - from unittest.mock import AsyncMock - +def _make_flow(entry) -> options_flow.NestQuestOptionsFlow: + """Create an options flow instance bound to a stubbed config entry.""" flow = options_flow.NestQuestOptionsFlow(config_entry=entry) flow.hass = SimpleNamespace() - flow.hass.auth = SimpleNamespace( - async_get_users=AsyncMock(return_value=users or []) - ) - if database is not None: - from custom_components.nestquest.const import DOMAIN - - flow.hass.data = { - DOMAIN: {entry.entry_id: SimpleNamespace(database=database)} - } return flow -def _user(user_id: str, name: str): - return SimpleNamespace(id=user_id, name=name) - - -def _open_allowlist_db(tmp_path, name): - """A real migrated temp database for the flow to read and write.""" - from unittest.mock import AsyncMock, MagicMock - - from custom_components.nestquest.db import NestQuestDatabase - from custom_components.nestquest.migrations import apply_migrations - - hass = MagicMock() - hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) - database = NestQuestDatabase(hass.async_add_executor_job) - _run(database.open(tmp_path / name)) - _run(apply_migrations(database)) - return database - - def _schema_values(schema) -> dict: """Extract the per-field default values from a real voluptuous schema.""" return { @@ -155,11 +93,11 @@ def test_options_flow_get_options_flow_is_ha_callback() -> None: def test_options_flow_constructs_through_2024_6_surface() -> None: """The handler constructs against the mocked 2024.6 base and inherits its contract.""" - entry = _make_entry(options={CONF_HORIZON_DAYS: 21}) + entry = _make_entry(options={CONF_UPDATE_INTERVAL: 90}) flow = options_flow.NestQuestOptionsFlow(config_entry=entry) assert isinstance(flow, _options_flow_base()) assert flow.config_entry is entry - assert flow.options == {CONF_HORIZON_DAYS: 21} + assert flow.options == {CONF_UPDATE_INTERVAL: 90} def test_options_flow_submits_through_2024_6_surface() -> None: @@ -194,86 +132,66 @@ def test_options_flow_prefills_from_defaults() -> None: flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(None)) values = _schema_values(result["data_schema"]) - assert values[CONF_HORIZON_DAYS] == DEFAULT_HORIZON_DAYS - assert values[CONF_DAY_ROLLOVER_TIME] == DEFAULT_DAY_ROLLOVER_TIME assert values[CONF_PANEL_IDLE_TIMEOUT] == DEFAULT_PANEL_IDLE_TIMEOUT + assert values[CONF_UPDATE_INTERVAL] == DEFAULT_UPDATE_INTERVAL + assert values[CONF_SNAPSHOT_STALENESS] == DEFAULT_SNAPSHOT_STALENESS def test_options_flow_prefills_from_entry_options() -> None: """Stored entry options win over entry data and defaults.""" entry = _make_entry( options={ - CONF_HORIZON_DAYS: 21, - CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, + CONF_UPDATE_INTERVAL: 90, + CONF_SNAPSHOT_STALENESS: 450, }, data={ - CONF_HORIZON_DAYS: 99, - CONF_DAY_ROLLOVER_TIME: "23:59", CONF_PANEL_IDLE_TIMEOUT: 999, + CONF_UPDATE_INTERVAL: 999, + CONF_SNAPSHOT_STALENESS: 999, }, ) result = _run(_make_flow(entry).async_step_init(None)) values = _schema_values(result["data_schema"]) - assert values[CONF_HORIZON_DAYS] == 21 - assert values[CONF_DAY_ROLLOVER_TIME] == "05:45" assert values[CONF_PANEL_IDLE_TIMEOUT] == 120 + assert values[CONF_UPDATE_INTERVAL] == 90 + assert values[CONF_SNAPSHOT_STALENESS] == 450 def test_options_flow_falls_back_to_entry_data() -> None: """Pre-upgrade entries without options fall back to entry.data.""" entry = _make_entry( data={ - CONF_HORIZON_DAYS: 10, - CONF_DAY_ROLLOVER_TIME: "01:15", CONF_PANEL_IDLE_TIMEOUT: 90, + CONF_UPDATE_INTERVAL: 60, + CONF_SNAPSHOT_STALENESS: 240, } ) result = _run(_make_flow(entry).async_step_init(None)) values = _schema_values(result["data_schema"]) - assert values[CONF_HORIZON_DAYS] == 10 - assert values[CONF_DAY_ROLLOVER_TIME] == "01:15" assert values[CONF_PANEL_IDLE_TIMEOUT] == 90 + assert values[CONF_UPDATE_INTERVAL] == 60 + assert values[CONF_SNAPSHOT_STALENESS] == 240 def test_options_flow_schema_applies_stored_defaults() -> None: - """A real voluptuous validate({}) fills every field from the stored defaults. - - Without a runtime database (and no auth in the bare harness), the - admin picker's default is the empty selection and its choice set is - empty — the three legacy settings are unaffected. - """ + """A real voluptuous validate({}) fills every field from the stored defaults.""" entry = _make_entry( options={ - CONF_HORIZON_DAYS: 21, - CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, CONF_SNAPSHOT_STALENESS: 450, - CONF_NOTIFY_TARGET: "notify.mobile_app_test", - CONF_MORNING_SUMMARY_TIME: "07:00", - CONF_AFTERNOON_REMINDER_TIME: "14:00", - CONF_END_OF_DAY_REPORT_TIME: "21:30", + CONF_API_BASE_URL: "http://panel.lan:8443", + CONF_PANEL_TOKEN: "stored-token", } ) result = _run(_make_flow(entry).async_step_init(None)) assert result["data_schema"]({}) == { - CONF_HORIZON_DAYS: 21, - CONF_DAY_ROLLOVER_TIME: "05:45", CONF_PANEL_IDLE_TIMEOUT: 120, CONF_UPDATE_INTERVAL: 90, CONF_SNAPSHOT_STALENESS: 450, - CONF_NOTIFY_TARGET: "notify.mobile_app_test", - CONF_MORNING_SUMMARY_TIME: "07:00", - CONF_AFTERNOON_REMINDER_TIME: "14:00", - CONF_END_OF_DAY_REPORT_TIME: "21:30", - CONF_MORNING_SUMMARY_ENABLED: True, - CONF_AFTERNOON_REMINDER_ENABLED: True, - CONF_END_OF_DAY_REPORT_ENABLED: True, - CONF_CELEBRATION_ENABLED: True, - CONF_API_BASE_URL: DEFAULT_API_BASE_URL, - CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN, - CONF_ADMIN_USER_IDS: [], + CONF_API_BASE_URL: "http://panel.lan:8443", + CONF_PANEL_TOKEN: "stored-token", } @@ -283,55 +201,14 @@ def test_options_flow_schema_coerces_field_types() -> None: result = _run(flow.async_step_init(None)) schema = result["data_schema"] validators = {marker.schema: validator for marker, validator in schema.schema.items()} - assert validators[CONF_HORIZON_DAYS] is int - assert validators[CONF_DAY_ROLLOVER_TIME] is str assert validators[CONF_PANEL_IDLE_TIMEOUT] is int + assert validators[CONF_UPDATE_INTERVAL] is int + assert validators[CONF_SNAPSHOT_STALENESS] is int # The Feature 18 API connection fields are free-text strings. assert validators[CONF_API_BASE_URL] is str assert validators[CONF_PANEL_TOKEN] is str -@pytest.mark.parametrize("bad_horizon", [0, -5]) -def test_options_flow_rejects_non_positive_horizon(bad_horizon: int) -> None: - """Zero or negative horizon values yield a field-level error, no exception.""" - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_HORIZON_DAYS: bad_horizon}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_HORIZON_DAYS: "invalid"} - - -def test_options_flow_rejects_malformed_time_not_a_time() -> None: - """Non-HH:MM strings are rejected with invalid_time.""" - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "noon"}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} - - -def test_options_flow_rejects_malformed_time_out_of_range() -> None: - """Out-of-range HH:MM values are rejected with invalid_time.""" - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "25:99"}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} - - -def test_options_flow_rejects_pasted_unicode_superscript_time() -> None: - """Pasted Unicode digits like '²²:²²' yield a field-level error, no exception.""" - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_DAY_ROLLOVER_TIME: "²²:²²"}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_DAY_ROLLOVER_TIME: "invalid_time"} - - @pytest.mark.parametrize("bad_timeout", ["abc", 0, -10, 10]) def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: """Non-numeric or too-low idle timeouts yield a field-level error.""" @@ -343,10 +220,21 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} +@pytest.mark.parametrize("bad_interval", ["abc", True, 0, -10, 29]) +def test_options_flow_rejects_invalid_update_interval(bad_interval) -> None: + """Non-numeric, boolean, and below-floor update intervals reject.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_UPDATE_INTERVAL: bad_interval}) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_UPDATE_INTERVAL: "invalid"} + + def test_options_flow_creates_entry_with_validated_keys() -> None: """A valid submit creates an entry with exactly the validated keys - (the legacy settings plus every later section's keys, filled from - defaults where the raw submit omitted them).""" + (the client-facing settings, filled from defaults where the raw + submit omitted them).""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -361,7 +249,7 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once( entry = _make_entry() entry.add_update_listener = lambda listener: registry.add(entry.entry_id, listener) - # Simulate setup registering the merged lifecycle update listener. + # Simulate setup registering the lifecycle update listener. from custom_components.nestquest import async_setup_entry _run(async_setup_entry(hass, entry)) @@ -380,14 +268,13 @@ def test_options_flow_create_entry_data_lands_in_entry_options_and_reloads_once( def test_options_flow_validation_never_raises() -> None: """Validation returns errors instead of raising for arbitrary junk input.""" - junk = {CONF_HORIZON_DAYS: None, CONF_DAY_ROLLOVER_TIME: None, CONF_PANEL_IDLE_TIMEOUT: None} + junk = {CONF_PANEL_IDLE_TIMEOUT: None, CONF_UPDATE_INTERVAL: "abc"} flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(junk)) assert result["type"] == "form" assert result["errors"] == { - CONF_HORIZON_DAYS: "invalid", - CONF_DAY_ROLLOVER_TIME: "invalid_time", CONF_PANEL_IDLE_TIMEOUT: "invalid", + CONF_UPDATE_INTERVAL: "invalid", } @@ -417,10 +304,10 @@ def test_options_strings_present_and_matching(strings: dict, translations_en: di assert init["title"] assert init["description"] data = init["data"] - for key in (CONF_HORIZON_DAYS, CONF_DAY_ROLLOVER_TIME, CONF_PANEL_IDLE_TIMEOUT): + for key in (CONF_PANEL_IDLE_TIMEOUT,): assert data.get(key), f"Missing field name for '{key}'" errors = options["errors"] - for key in ("invalid", "invalid_time"): + for key in ("invalid",): assert errors.get(key), f"Missing error string for '{key}'" @@ -476,349 +363,6 @@ def test_options_flow_no_hardcoded_domain_or_db_filename() -> None: f"Hard-coded database filename in {py_file.name}:{node.lineno}" ) -# --------------------------------------------------------------------------- -# Admin allowlist picker (Feature 03) -# --------------------------------------------------------------------------- - - -def test_options_flow_picker_lists_existing_users(tmp_path) -> None: - """The picker offers EXISTING HA users (id -> name), never free text.""" - import voluptuous as vol - - database = _open_allowlist_db(tmp_path, "picker-users.db") - try: - from custom_components.nestquest.admin_allowlist import set_admin_ids - - _run(set_admin_ids(database, ["u1"])) - entry = _make_entry() - flow = _make_flow( - entry, - users=[_user("u1", "Joshua"), _user("u2", "Sam")], - database=database, - ) - result = _run(flow.async_step_init(None)) - schema = result["data_schema"] - marker = next( - m for m in schema.schema - if getattr(m, "schema", None) == CONF_ADMIN_USER_IDS - ) - # Default is the CURRENT database allowlist. - values = _schema_values(schema) - assert values[CONF_ADMIN_USER_IDS] == ["u1"] - # Both users are selectable; an unknown id is rejected by the - # schema itself (this is what real HA submits go through). - validated = schema( - { - CONF_HORIZON_DAYS: 7, - CONF_DAY_ROLLOVER_TIME: "03:30", - CONF_PANEL_IDLE_TIMEOUT: 60, - CONF_ADMIN_USER_IDS: ["u1", "u2"], - } - ) - assert validated[CONF_ADMIN_USER_IDS] == ["u1", "u2"] - with pytest.raises(vol.Invalid): - schema( - { - CONF_HORIZON_DAYS: 7, - CONF_DAY_ROLLOVER_TIME: "03:30", - CONF_PANEL_IDLE_TIMEOUT: 60, - CONF_ADMIN_USER_IDS: ["impostor"], - } - ) - finally: - _run(database.close()) - - -def test_options_flow_submit_applies_selection_to_database(tmp_path) -> None: - database = _open_allowlist_db(tmp_path, "picker-apply.db") - try: - from custom_components.nestquest.admin_allowlist import ( - list_admin_ids, - set_admin_ids, - ) - - _run(set_admin_ids(database, ["u1"])) - entry = _make_entry() - flow = _make_flow( - entry, - users=[_user("u1", "Joshua"), _user("u2", "Sam")], - database=database, - ) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2"]}) - ) - assert result["type"] == "create_entry" - assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"] - # The allowlist (and the entry's recovery copy) carry the pick. - assert await_gather(list_admin_ids(database)) == ["u2"] - finally: - _run(database.close()) - - -def await_gather(coro): - return _run(coro) - - -def test_options_flow_empty_selection_refused(tmp_path) -> None: - """Saving zero admins is refused: an empty allowlist fails closed.""" - database = _open_allowlist_db(tmp_path, "picker-empty.db") - try: - from custom_components.nestquest.admin_allowlist import ( - list_admin_ids, - set_admin_ids, - ) - - _run(set_admin_ids(database, ["u1"])) - entry = _make_entry() - flow = _make_flow( - entry, - users=[_user("u1", "Joshua")], - database=database, - ) - result = _run( - flow.async_step_init({**VALID_INPUT, CONF_ADMIN_USER_IDS: []}) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_ADMIN_USER_IDS: "no_admins"} - assert await_gather(list_admin_ids(database)) == ["u1"] - finally: - _run(database.close()) - - -def test_options_flow_unknown_selection_refused(tmp_path) -> None: - database = _open_allowlist_db(tmp_path, "picker-unknown.db") - try: - from custom_components.nestquest.admin_allowlist import ( - list_admin_ids, - set_admin_ids, - ) - - _run(set_admin_ids(database, ["u1"])) - entry = _make_entry() - flow = _make_flow( - entry, - users=[_user("u1", "Joshua")], - database=database, - ) - result = _run( - flow.async_step_init( - {**VALID_INPUT, CONF_ADMIN_USER_IDS: ["impostor"]} - ) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"} - assert await_gather(list_admin_ids(database)) == ["u1"] - finally: - _run(database.close()) - - -def test_options_flow_submit_without_admin_key_keeps_allowlist(tmp_path) -> None: - """Raw callers omitting the key (pre-picker payloads) do not - touch the allowlist; the CURRENT allowlist is carried forward on - the options copy so no removed admin can be resurrected later.""" - database = _open_allowlist_db(tmp_path, "picker-legacy.db") - try: - from custom_components.nestquest.admin_allowlist import ( - list_admin_ids, - set_admin_ids, - ) - - _run(set_admin_ids(database, ["u1"])) - entry = _make_entry() - flow = _make_flow( - entry, - users=[_user("u1", "Joshua")], - database=database, - ) - result = _run(flow.async_step_init(dict(VALID_INPUT))) - assert result["type"] == "create_entry" - assert result["data"][CONF_ADMIN_USER_IDS] == ["u1"] - assert await_gather(list_admin_ids(database)) == ["u1"] - finally: - _run(database.close()) - - -def test_options_flow_duplicate_selection_refused(tmp_path) -> None: - """Duplicate ids surface as a field error, not an unhandled - ValueError from the business layer.""" - database = _open_allowlist_db(tmp_path, "picker-dupe.db") - try: - from custom_components.nestquest.admin_allowlist import ( - list_admin_ids, - set_admin_ids, - ) - - _run(set_admin_ids(database, ["u1"])) - entry = _make_entry() - flow = _make_flow( - entry, - users=[_user("u1", "Joshua"), _user("u2", "Sam")], - database=database, - ) - result = _run( - flow.async_step_init( - {**VALID_INPUT, CONF_ADMIN_USER_IDS: ["u2", "u2"]} - ) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_ADMIN_USER_IDS: "invalid_admin"} - assert await_gather(list_admin_ids(database)) == ["u1"] - finally: - _run(database.close()) - - -def test_options_flow_omitted_picker_carries_allowlist_forward( - tmp_path, -) -> None: - """An omitted picker field must not drop the allowlist copy from - the returned options: HA replaces entry.options wholesale, and a - later database loss must not resurrect removed admins from the - stale config-flow copy in entry.data.""" - database = _open_allowlist_db(tmp_path, "picker-carry.db") - try: - from custom_components.nestquest.admin_allowlist import ( - set_admin_ids, - ) - - _run(set_admin_ids(database, ["u2"])) - entry = _make_entry() - flow = _make_flow( - entry, - users=[_user("u1", "Joshua"), _user("u2", "Sam")], - database=database, - ) - result = _run(flow.async_step_init(dict(VALID_INPUT))) - assert result["type"] == "create_entry" - # The current allowlist rides along on the legacy three-key path. - assert result["data"][CONF_ADMIN_USER_IDS] == ["u2"] - assert result["data"][CONF_HORIZON_DAYS] == VALID_INPUT[CONF_HORIZON_DAYS] - finally: - _run(database.close()) - - -def test_options_flow_notification_section_defaults() -> None: - """A bare submit fills the notification section from const defaults: - 08:00 / 15:00 / 20:00 — the report deliberately BEFORE the midnight - rollover sweep — an empty notify target, and all toggles enabled.""" - flow = _make_flow(_make_entry()) - result = _run(flow.async_step_init(dict(VALID_INPUT))) - assert result["type"] == "create_entry" - assert result["data"][CONF_MORNING_SUMMARY_TIME] == "08:00" - assert result["data"][CONF_AFTERNOON_REMINDER_TIME] == "15:00" - assert result["data"][CONF_END_OF_DAY_REPORT_TIME] == "20:00" - assert result["data"][CONF_NOTIFY_TARGET] == "" - for toggle in ( - CONF_MORNING_SUMMARY_ENABLED, - CONF_AFTERNOON_REMINDER_ENABLED, - CONF_END_OF_DAY_REPORT_ENABLED, - CONF_CELEBRATION_ENABLED, - ): - assert result["data"][toggle] is True - - -def test_options_flow_notification_times_must_be_strict_hh_mm() -> None: - flow = _make_flow(_make_entry()) - for bad_time in ("9:00", "25:00", "08:0", "08-00", 800): - result = _run( - flow.async_step_init( - { - **VALID_INPUT, - CONF_MORNING_SUMMARY_TIME: bad_time, - } - ) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_MORNING_SUMMARY_TIME: "invalid_time"} - - -def test_options_flow_notify_target_validation() -> None: - """The empty target is VALID (it is the shipped default and must be - savable); only malformed non-empty values reject.""" - flow = _make_flow(_make_entry()) - # The empty default saves cleanly. - result = _run( - flow.async_step_init( - {**VALID_INPUT, CONF_NOTIFY_TARGET: ""} - ) - ) - assert result["type"] == "create_entry" - assert result["data"][CONF_NOTIFY_TARGET] == "" - - # Malformed non-empty values reject. - for bad_target in (" notify.x ", " ", 42): - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init( - { - **VALID_INPUT, - CONF_NOTIFY_TARGET: bad_target, - } - ) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_NOTIFY_TARGET: "invalid"} - - -def test_options_flow_toggles_must_be_real_booleans() -> None: - flow = _make_flow(_make_entry()) - result = _run( - flow.async_step_init( - { - **VALID_INPUT, - CONF_CELEBRATION_ENABLED: "on", - } - ) - ) - assert result["type"] == "form" - assert result["errors"] == {CONF_CELEBRATION_ENABLED: "invalid"} - - -def test_options_flow_notification_stored_values_round_trip() -> None: - """Stored notification settings are the form's defaults and a raw - submit without them keeps the stored values.""" - entry = _make_entry( - options={ - **VALID_INPUT, - CONF_UPDATE_INTERVAL: 90, - CONF_NOTIFY_TARGET: "notify.mobile_app_dad", - CONF_MORNING_SUMMARY_TIME: "07:15", - CONF_AFTERNOON_REMINDER_TIME: "16:45", - CONF_END_OF_DAY_REPORT_TIME: "21:00", - CONF_MORNING_SUMMARY_ENABLED: False, - CONF_AFTERNOON_REMINDER_ENABLED: True, - CONF_END_OF_DAY_REPORT_ENABLED: False, - CONF_CELEBRATION_ENABLED: True, - } - ) - flow = _make_flow(entry) - form = _run(flow.async_step_init(None)) - schema_defaults = form["data_schema"]({}) - assert schema_defaults[CONF_NOTIFY_TARGET] == "notify.mobile_app_dad" - assert schema_defaults[CONF_MORNING_SUMMARY_TIME] == "07:15" - assert schema_defaults[CONF_END_OF_DAY_REPORT_ENABLED] is False - - # A raw submit carrying only the legacy keys keeps the stored - # notification settings. - result = _run(flow.async_step_init(dict(VALID_INPUT))) - assert result["type"] == "create_entry" - assert result["data"][CONF_NOTIFY_TARGET] == "notify.mobile_app_dad" - assert result["data"][CONF_MORNING_SUMMARY_TIME] == "07:15" - assert result["data"][CONF_MORNING_SUMMARY_ENABLED] is False - assert result["data"][CONF_CELEBRATION_ENABLED] is True - - -def test_options_flow_no_hard_coded_personal_target_in_source() -> None: - """The notify target's DEFAULT is the empty string: the household - fills their own service name, and no personal target ships as a - value anywhere in the const/flow defaults.""" - from custom_components.nestquest import const - - assert getattr(const, "CONF_NOTIFY_TARGET", None) == "notify_target" - # No const default carries a concrete notify service. - assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == "" - form = _run(_make_flow(_make_entry()).async_step_init(None)) - assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == "" - # --------------------------------------------------------------------------- # Panel-plane API connection section (Feature 18) @@ -889,7 +433,7 @@ def test_options_flow_api_section_stored_values_round_trip() -> None: assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443" assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token" - # A raw submit carrying only the legacy keys keeps the stored + # A raw submit carrying only the panel-idle key keeps the stored # API connection settings. result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -976,7 +520,7 @@ def test_options_flow_staleness_equal_to_interval_saves() -> None: def test_options_flow_staleness_defaults_saved() -> None: """A bare submit saves the staleness default (three update - intervals) alongside the other timing defaults.""" + intervals) alongside the other defaults.""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -998,7 +542,7 @@ def test_options_flow_staleness_stored_values_round_trip() -> None: schema_defaults = form["data_schema"]({}) assert schema_defaults[CONF_SNAPSHOT_STALENESS] == 1200 - # A raw submit carrying only the legacy keys keeps the stored + # A raw submit carrying only the panel-idle key keeps the stored # staleness (and never trips the interval cross-check). result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" diff --git a/tests/test_presence.py b/tests/test_presence.py index dd33184..fd17270 100644 --- a/tests/test_presence.py +++ b/tests/test_presence.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.presence import ( +from custom_components.nestquest.core.presence import ( MAX_CYCLE_LENGTH_WEEKS, PresenceEngine, PresenceOverride, diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index d1d01da..6a534c8 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -7,20 +7,20 @@ import re import pytest -from custom_components.nestquest.dao_children import ChildrenDao +from custom_components.nestquest.core.dao_children import ChildrenDao from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS -from custom_components.nestquest.dao_instances import ( +from custom_components.nestquest.core.dao_instances import ( CompletionEventsDao, QuestInstancesDao, ) -from custom_components.nestquest.dao_rules import ( +from custom_components.nestquest.core.dao_rules import ( QuestDefinitionsDao, ScheduleRulesDao, schedule_rule_to_storage, ) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations -from custom_components.nestquest.quest_definitions import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.migrations import apply_migrations +from custom_components.nestquest.core.quest_definitions import ( CreatedQuestDefinition, assign_child, create_quest_definition, @@ -31,7 +31,7 @@ from custom_components.nestquest.quest_definitions import ( set_quest_definition_active, unassign_child, ) -from custom_components.nestquest.recurrence import RuleType, ScheduleRule +from custom_components.nestquest.core.recurrence import RuleType, ScheduleRule def _run(coro): @@ -1150,7 +1150,7 @@ def test_concurrent_assign_unassign_returns_consistent_roster( ) bo = await children.create("Bo", NOW) - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_list = dao_rules.QuestDefinitionsDao.list_assignees started = asyncio.Event() @@ -1666,7 +1666,7 @@ def test_list_query_snapshot_coherent_under_racing_edit(tmp_path) -> None: start_date="2026-09-14", ) - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_list = dao_rules.QuestDefinitionsDao.list_windows started = asyncio.Event() @@ -1732,7 +1732,7 @@ def test_list_definitions_for_child_snapshot_coherent_under_racing_unassign( database, "Brush teeth", _daily_rule(), [child.id], ["morning"] ) - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_list = dao_rules.QuestDefinitionsDao.list_assignees started = asyncio.Event() @@ -1795,7 +1795,7 @@ def test_list_active_definitions_snapshot_coherent_under_racing_edit( start_date="2026-09-14", ) - import custom_components.nestquest.dao_rules as dao_rules + import custom_components.nestquest.core.dao_rules as dao_rules original_list = dao_rules.QuestDefinitionsDao.list_windows started = asyncio.Event() diff --git a/tests/test_recurrence_bounds.py b/tests/test_recurrence_bounds.py index 2098d24..c1e81d6 100644 --- a/tests/test_recurrence_bounds.py +++ b/tests/test_recurrence_bounds.py @@ -12,7 +12,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_consolidated.py b/tests/test_recurrence_consolidated.py index 441cc7d..9dfaf8f 100644 --- a/tests/test_recurrence_consolidated.py +++ b/tests/test_recurrence_consolidated.py @@ -15,7 +15,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( ScheduleRule, RuleType, occurs_on, @@ -395,7 +395,7 @@ def test_recurrence_engine_imports_nothing_forbidden() -> None: module_file = Path( importlib.util.find_spec( - "custom_components.nestquest.recurrence" + "custom_components.nestquest.core.recurrence" ).origin ) tree = ast.parse(module_file.read_text()) diff --git a/tests/test_recurrence_daily.py b/tests/test_recurrence_daily.py index 4505d38..e8a8c79 100644 --- a/tests/test_recurrence_daily.py +++ b/tests/test_recurrence_daily.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, @@ -219,7 +219,7 @@ def test_occurs_on_rejects_unknown_shapes_loudly() -> None: than a silent wrong answer. Forged via a private enum extension because ScheduleRule validation rejects unknown names.""" import enum as enum_module - from custom_components.nestquest import recurrence as mod + from custom_components.nestquest.core import recurrence as mod ForgedType = enum_module.Enum( "ForgedType", {"UNKNOWN_SHAPE": "unknown_shape"} diff --git a/tests/test_recurrence_model.py b/tests/test_recurrence_model.py index 095ac03..b0cc2cf 100644 --- a/tests/test_recurrence_model.py +++ b/tests/test_recurrence_model.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, @@ -344,12 +344,12 @@ def test_purity_guard_logic_catches_probe_statements(tmp_path) -> None: "from homeassistant.core import HomeAssistant", "import sqlite3", "from .db import NestQuestDatabase", - "from custom_components.nestquest.db import NestQuestDatabase", + "from custom_components.nestquest.core.db import NestQuestDatabase", "from custom_components.nestquest import db", - "import custom_components.nestquest.dao_rules", + "import custom_components.nestquest.core.dao_rules", "from . import db", "from . import schema", - "from custom_components.nestquest.migrations import apply_migrations", + "from custom_components.nestquest.core.migrations import apply_migrations", ] for probe in probes: offenders = _scan_forbidden_imports(probe) diff --git a/tests/test_recurrence_monthly_day.py b/tests/test_recurrence_monthly_day.py index 8830523..76a3e61 100644 --- a/tests/test_recurrence_monthly_day.py +++ b/tests/test_recurrence_monthly_day.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_monthly_weekday.py b/tests/test_recurrence_monthly_weekday.py index 2e040e9..fd00641 100644 --- a/tests/test_recurrence_monthly_weekday.py +++ b/tests/test_recurrence_monthly_weekday.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_property.py b/tests/test_recurrence_property.py index eb2ef04..790f513 100644 --- a/tests/test_recurrence_property.py +++ b/tests/test_recurrence_property.py @@ -18,7 +18,7 @@ import random import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_weekly.py b/tests/test_recurrence_weekly.py index 10bd181..3c15411 100644 --- a/tests/test_recurrence_weekly.py +++ b/tests/test_recurrence_weekly.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_recurrence_yearly.py b/tests/test_recurrence_yearly.py index d544f39..e8859be 100644 --- a/tests/test_recurrence_yearly.py +++ b/tests/test_recurrence_yearly.py @@ -5,7 +5,7 @@ import datetime import pytest -from custom_components.nestquest.recurrence import ( +from custom_components.nestquest.core.recurrence import ( RuleType, RuleValidationError, ScheduleRule, diff --git a/tests/test_schema.py b/tests/test_schema.py index 0067134..4a526de 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -6,8 +6,8 @@ import sqlite3 import pytest -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.schema import ( +from custom_components.nestquest.core.db import NestQuestDatabase +from custom_components.nestquest.core.schema import ( SCHEMA_V1_ADMIN_USERS_DDL, SCHEMA_V1_CHILDREN_DDL, SCHEMA_V1_COMPLETION_EVENTS_DDL, diff --git a/tests/test_sensors.py b/tests/test_sensors.py index fa29adb..2b33d9e 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -1,75 +1,132 @@ -"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2).""" +"""Tests for the per-child daily count and percentage sensors (Feature 10, task 2). + +Since the DB removal the snapshot comes from the API service's panel +route, so these tests script the route's payload (the documented +Feature 16 shape ``test_coordinator.py`` also uses) through +:class:`conftest.StubSnapshotClient` instead of seeding a local +database: the coordinator rebuilds the same core dataclasses the +sensors read, and every assertion below is on entity state. +""" from __future__ import annotations import datetime import pytest -from conftest import wire_entry_to_registry +from conftest import StubSnapshotClient, 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 -from custom_components.nestquest.completion import complete_instance from custom_components.nestquest.const import DOMAIN -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import create_quest_definition -from custom_components.nestquest.recurrence import ScheduleRule from custom_components.nestquest.sensor import ( MAX_STATE_LENGTH, _state_within_limit, ) -#: The seeding horizon: instances exist for today and a few days out, -#: enough for the day snapshot without slowing the walk down. -SEED_HORIZON_DAYS = 3 -#: The documented per-child day sensor kinds (unique_id suffixes). -SENSOR_KINDS = ( - "quests_due_today", - "quests_completed_today", - "quests_remaining_today", - "completion_pct_today", -) +def _today_iso() -> str: + return datetime.date.today().isoformat() -def _today() -> datetime.date: - return datetime.date.today() +def _instance(instance_id: int, child_id: int, **overrides) -> dict: + """One panel instance in the API route's payload shape.""" + payload = { + "id": instance_id, + "definition_id": 3, + "child_id": child_id, + "title": "Brush teeth", + "icon": "🦷", + "window": "morning", + "due_time": "08:00", + "state": "open", + "overdue": False, + "completed_at": None, + "on_time": None, + } + payload.update(overrides) + return payload -async def _seed_children(database): - """Three active children: Ada and Bo share one daily quest; Cory has none.""" - ada = await create_child(database, "Ada") - bo = await create_child(database, "Bo") - cory = await create_child(database, "Cory") - today = _today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} - ), - [ada.id, bo.id], - ["morning"], - ) - end = (today + datetime.timedelta(days=SEED_HORIZON_DAYS)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - return ada, bo, cory +def _child(child_id: int, name: str, instances=(), **overrides) -> dict: + """One child in the API route's payload shape, counts precomputed. - -async def _setup_seeded_entry(hass, make_entry): - """Set up the integration, seed three children, refresh: sensors exist. - - Children are seeded AFTER setup (the entry's database only exists - once setup opens it), so the sensors appear through the refresh - listener — the same path production takes when a child is added. + The API precomputes the per-child rollups from the full day; the + builder here derives them from the instance list so a payload can + never drift from its own counts. """ - entry = wire_entry_to_registry(make_entry(), hass.registry) + completed = sum(1 for i in instances if i["state"] == "completed") + due = max(len(instances), 0) + payload = { + "child_id": child_id, + "child_name": name, + "present": True, + "next_present": None, + "due_today": due, + "completed_today": completed, + "remaining_today": due - completed, + "completion_pct": 100 if due == 0 else round(completed / due * 100), + "instances": list(instances), + } + payload.update(overrides) + return payload + + +def _payload(children) -> dict: + return { + "today_iso": _today_iso(), + "cycle_day": 0, + "children": list(children), + } + + +#: The seeded household: Ada and Bo share one daily quest, Cory has none. +ADA = 1 +BO = 2 +CORY = 3 + + +def _seeded_payload() -> dict: + return _payload( + [ + _child(ADA, "Ada", [_instance(7, ADA)]), + _child(BO, "Bo", [_instance(8, BO)]), + _child(CORY, "Cory"), + ] + ) + + +def _completed_payload() -> dict: + """The same household after the API recorded Ada's completion.""" + return _payload( + [ + _child( + ADA, + "Ada", + [_instance(7, ADA, state="completed", completed_at=f"{_today_iso()}T07:55:00+00:00", on_time=True)], + ), + _child(BO, "Bo", [_instance(8, BO)]), + _child(CORY, "Cory"), + ] + ) + + +async def _setup_entry(hass, make_entry, *payloads): + """Set up the integration with a scripted snapshot client. + + The client serves the payloads in order (repeating the last), so + setup's first refresh and any later explicit refresh each get the + scripted state — the same path production takes through the API. + """ + entry = wire_entry_to_registry( + make_entry( + data={"api_base_url": "http://api.test:8000", "panel_token": "tok"} + ), + hass.registry, + ) + client = set_coordinator_client(entry, StubSnapshotClient(*payloads)) assert await async_setup_entry(hass, entry) is True coordinator = entry.runtime_data.coordinator - database = entry.runtime_data.database - ada, bo, cory = await _seed_children(database) - await coordinator.async_refresh() - return entry, coordinator, (ada, bo, cory) + assert coordinator.api_client is client + return entry, coordinator, client def _snapshot_child(coordinator, child_id): @@ -85,47 +142,31 @@ def _sensor(hass, child_id, kind): return hass.entities[f"{DOMAIN}_child_{child_id}_{kind}"] -async def _complete_first_instance(database, coordinator, child) -> None: - """Complete the child's first open instance through the business layer.""" - snapshot_child = _snapshot_child(coordinator, child.id) - instance_id = snapshot_child.instances[0].instance_id - await complete_instance( - database, - instance_id, - actor_source="panel", - actor_child_id=child.id, - ) - - async def test_every_active_child_gets_five_sensors_with_documented_identity( hass, make_entry ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload(), _completed_payload() ) - database = entry.runtime_data.database assert hass.config_entries.forwarded_platforms == [ "sensor", "binary_sensor", ] - await _complete_first_instance(database, coordinator, ada) await coordinator.async_refresh() - # Two quest children plus the zero-quest child: three children x - # (five sensors + two binary sensors) + three household sensors, - # no duplicates. + # Three children x (five sensors + two binary sensors) + three + # household sensors, no duplicates. assert len(hass.entities) == 24 expected = { - ada.id: ("Ada", 1, 1, 0, 100), - bo.id: ("Bo", 1, 0, 1, 0), - cory.id: ("Cory", 0, 0, 0, 100), + ADA: ("Ada", 1, 1, 0, 100), + BO: ("Bo", 1, 0, 1, 0), + CORY: ("Cory", 0, 0, 0, 100), } for child_id, (name, due, completed, remaining, pct) in expected.items(): due_entity = _sensor(hass, child_id, "quests_due_today") completed_entity = _sensor(hass, child_id, "quests_completed_today") remaining_entity = _sensor(hass, child_id, "quests_remaining_today") pct_entity = _sensor(hass, child_id, "completion_pct_today") - next_entity = _sensor(hass, child_id, "next_quest") assert due_entity.name == f"NestQuest {name} quests due today" assert completed_entity.name == ( f"NestQuest {name} quests completed today" @@ -151,11 +192,11 @@ async def test_every_active_child_gets_five_sensors_with_documented_identity( async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, _coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) - due_entity = _sensor(hass, cory.id, "quests_due_today") - pct_entity = _sensor(hass, cory.id, "completion_pct_today") + due_entity = _sensor(hass, CORY, "quests_due_today") + pct_entity = _sensor(hass, CORY, "completion_pct_today") assert due_entity.native_value == 0 assert pct_entity.native_value == 100, ( "zero quests is a perfect day, not a division error" @@ -168,14 +209,11 @@ async def test_zero_quest_child_is_a_perfect_day(hass, make_entry) -> None: async def test_due_today_attributes_carry_the_panel_payload( hass, make_entry ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _completed_payload() ) - database = entry.runtime_data.database - await _complete_first_instance(database, coordinator, ada) - await coordinator.async_refresh() - ada_due = _sensor(hass, ada.id, "quests_due_today") + ada_due = _sensor(hass, ADA, "quests_due_today") attributes = ada_due.extra_state_attributes assert set(attributes) == { "instances", @@ -184,7 +222,7 @@ async def test_due_today_attributes_carry_the_panel_payload( "child_name", "present", } - assert attributes["child_id"] == ada.id + assert attributes["child_id"] == ADA assert attributes["child_name"] == "Ada" assert attributes["present"] is True [instance] = attributes["instances"] @@ -201,7 +239,7 @@ async def test_due_today_attributes_carry_the_panel_payload( "completed_at", "on_time", } - assert instance["child_id"] == ada.id + assert instance["child_id"] == ADA assert instance["title"] == "Brush teeth" assert instance["window"] == "morning" assert instance["state"] == "completed", ( @@ -214,7 +252,7 @@ async def test_due_today_attributes_carry_the_panel_payload( "with nothing missed, admin and panel payloads are identical" ) - bo_due = _sensor(hass, bo.id, "quests_due_today") + bo_due = _sensor(hass, BO, "quests_due_today") [bo_instance] = bo_due.extra_state_attributes["instances"] assert bo_instance["state"] == "open" assert bo_instance["completed_at"] is None @@ -222,64 +260,64 @@ async def test_due_today_attributes_carry_the_panel_payload( async def test_due_today_payload_omits_missed_admin_equals_panel( - hass, make_entry, monkeypatch + hass, make_entry ) -> None: """D-009: the panel payload omits missed instances. 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. + quests is the PWA, reading the API directly). The integration + renders exactly what the route sent: a snapshot can never carry a + ``missed`` view, ``admin_instances`` equals ``instances``, and the + precomputed counts arrive untouched — pinned here with an open + instance whose derived state reads missed (a past ``due_time`` + with the counts already rolled up the way the route builds them). """ - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + payload = _payload( + [ + _child( + BO, + "Bo", + [ + _instance( + 8, + BO, + due_time="00:01", + overdue=True, + ) + ], + ) + ] ) - import custom_components.nestquest.core.snapshot as snapshot_module + entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload) - tomorrow = _today() + datetime.timedelta(days=1) - real_derive_state = snapshot_module.derive_state - - def _derive_against_pinned_future(instance, latest, today): - return real_derive_state(instance, latest, tomorrow) - - monkeypatch.setattr( - snapshot_module, "derive_state", _derive_against_pinned_future - ) - await coordinator.async_refresh() - - bo_due = _sensor(hass, bo.id, "quests_due_today") + bo_due = _sensor(hass, BO, "quests_due_today") attributes = bo_due.extra_state_attributes - assert attributes["instances"] == [], ( - "missed instances are omitted from the panel payload" - ) - assert attributes["admin_instances"] == [], ( + assert attributes["instances"], "an open row renders in the panel payload" + assert attributes["admin_instances"] == attributes["instances"], ( "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 + # The counts arrive precomputed from the route's own build, which + # rolls the FULL day (any missed row included) into the numbers. + assert _sensor(hass, BO, "quests_completed_today").native_value == 0 + assert _sensor(hass, BO, "quests_remaining_today").native_value == 1 -@pytest.mark.parametrize("kind", SENSOR_KINDS) +@pytest.mark.parametrize("kind", ( + "quests_due_today", + "quests_completed_today", + "quests_remaining_today", + "completion_pct_today", +)) async def test_no_sensor_state_exceeds_the_255_character_limit( hass, make_entry, kind ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, _coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) - for child in (ada, bo, cory): - entity = _sensor(hass, child.id, kind) + for child_id in (ADA, BO, CORY): + entity = _sensor(hass, child_id, kind) state = entity.native_value assert len(str(state)) <= MAX_STATE_LENGTH @@ -295,14 +333,13 @@ def test_state_within_limit_refuses_overlong_strings() -> None: async def test_sensors_update_on_refresh_after_a_completion( hass, make_entry ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload(), _completed_payload() ) - database = entry.runtime_data.database - completed_entity = _sensor(hass, ada.id, "quests_completed_today") - remaining_entity = _sensor(hass, ada.id, "quests_remaining_today") - pct_entity = _sensor(hass, ada.id, "completion_pct_today") - due_entity = _sensor(hass, ada.id, "quests_due_today") + completed_entity = _sensor(hass, ADA, "quests_completed_today") + remaining_entity = _sensor(hass, ADA, "quests_remaining_today") + pct_entity = _sensor(hass, ADA, "completion_pct_today") + due_entity = _sensor(hass, ADA, "quests_due_today") assert completed_entity.native_value == 0 assert remaining_entity.native_value == 1 assert pct_entity.native_value == 0 @@ -318,7 +355,6 @@ async def test_sensors_update_on_refresh_after_a_completion( due_entity.async_write_ha_state = _record_write - await _complete_first_instance(database, coordinator, ada) await coordinator.async_refresh() assert completed_entity.native_value == 1 @@ -330,8 +366,8 @@ async def test_sensors_update_on_refresh_after_a_completion( async def test_repeated_refreshes_do_not_duplicate_entities( hass, make_entry ) -> None: - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) await coordinator.async_refresh() await coordinator.async_refresh() @@ -341,8 +377,9 @@ async def test_repeated_refreshes_do_not_duplicate_entities( async def test_setup_with_no_children_registers_only_household( hass, make_entry ) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True + entry, _coordinator, _client = await _setup_entry( + hass, make_entry, _payload([]) + ) # No children means no per-child entities; the three household # rollups still exist (an empty household is a valid 0). assert set(hass.entities) == { @@ -354,8 +391,7 @@ async def test_setup_with_no_children_registers_only_household( async def test_unload_unloads_platforms(hass, make_entry) -> None: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True + entry, _coordinator, _client = await _setup_entry(hass, make_entry) assert await async_unload_entry(hass, entry) is True assert hass.config_entries.unloaded_platforms == [ "sensor", @@ -366,20 +402,23 @@ async def test_unload_unloads_platforms(hass, make_entry) -> None: async def test_rename_propagates_to_name_and_device_label( hass, make_entry ) -> None: - """A manage_child rename shows on the next refresh: the name and - device label re-derive from the snapshot while the unique_id (and - therefore history) is untouched.""" - from custom_components.nestquest.children import edit_child - - entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( - hass, make_entry + """A rename in the API's snapshot shows on the next refresh: the + name and device label re-derive from the snapshot while the + unique_id (and therefore history) is untouched.""" + renamed = _payload( + [ + _child(ADA, "Mirren", [_instance(7, ADA)]), + _child(BO, "Bo", [_instance(8, BO)]), + _child(CORY, "Cory"), + ] ) - database = entry.runtime_data.database - due_sensor = _sensor(hass, ada.id, "quests_due_today") + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload(), renamed + ) + due_sensor = _sensor(hass, ADA, "quests_due_today") original_unique_id = due_sensor.unique_id assert due_sensor.name.startswith("NestQuest Ada ") - await edit_child(database, ada.id, display_name="Mirren") await coordinator.async_refresh() assert due_sensor.name.startswith("NestQuest Mirren ") @@ -391,12 +430,11 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime( hass, make_entry ) -> None: """When HA reports platforms still loaded, the unload must NOT - close the database the live entities read: it returns False and - keeps the runtime record for a retry.""" - entry, _coordinator, _children = await _setup_seeded_entry( - hass, make_entry + tear down the coordinator the live entities read: it returns False + and keeps the runtime record for a retry.""" + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) - database = entry.runtime_data.database async def _refuse_unload(entry_arg, platforms): return False @@ -404,26 +442,27 @@ async def test_failed_platform_unload_returns_false_and_retains_runtime( hass.config_entries.async_unload_platforms = _refuse_unload assert await async_unload_entry(hass, entry) is False assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data - assert database.connected is True + assert entry.runtime_data.coordinator is coordinator + async def test_next_quest_sensor_reports_earliest_open_quest( hass, make_entry ) -> None: """The next-quest sensor carries the earliest open quest's title in state with the full shape in attributes.""" - entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( - hass, make_entry + entry, coordinator, _client = await _setup_entry( + hass, make_entry, _seeded_payload() ) - next_entity = _sensor(hass, ada.id, "next_quest") + next_entity = _sensor(hass, ADA, "next_quest") assert next_entity.name == f"NestQuest Ada next quest" assert next_entity.native_value == "Brush teeth" attributes = next_entity.extra_state_attributes assert attributes["title"] == "Brush teeth" assert attributes["window"] == "morning" assert attributes["instance_id"] == _snapshot_child( - coordinator, ada.id + coordinator, ADA ).instances[0].instance_id - assert attributes["child_id"] == ada.id + assert attributes["child_id"] == ADA async def test_next_quest_sensor_reports_none_sentinel_when_day_clear( @@ -431,14 +470,11 @@ async def test_next_quest_sensor_reports_none_sentinel_when_day_clear( ) -> None: """A child with every quest done (and a zero-quest child) reports the documented sentinel, never an empty string.""" - entry, coordinator, (ada, _bo, cory) = await _setup_seeded_entry( - hass, make_entry + entry, _coordinator, _client = await _setup_entry( + hass, make_entry, _completed_payload() ) - database = entry.runtime_data.database - await _complete_first_instance(database, coordinator, ada) - await coordinator.async_refresh() - assert _sensor(hass, ada.id, "next_quest").native_value == "none" - assert _sensor(hass, cory.id, "next_quest").native_value == "none" + assert _sensor(hass, ADA, "next_quest").native_value == "none" + assert _sensor(hass, CORY, "next_quest").native_value == "none" async def test_next_quest_state_truncates_over_the_limit( @@ -446,23 +482,15 @@ async def test_next_quest_state_truncates_over_the_limit( ) -> None: """A quest title longer than the state limit is truncated; the attribute keeps the full title.""" - entry, coordinator, (ada, _bo, _cory) = await _setup_seeded_entry( - hass, make_entry + long_title = "A very long quest title. " * 30 + payload = _payload( + [_child(ADA, "Ada", [_instance(7, ADA, title=long_title)])] ) - database = entry.runtime_data.database - from custom_components.nestquest.quest_definitions import ( - edit_quest_definition, - ) - - snapshot_child = _snapshot_child(coordinator, ada.id) - await edit_quest_definition( - database, - snapshot_child.instances[0].definition_id, - title="A very long quest title. " * 30, - ) - await coordinator.async_refresh() - next_entity = _sensor(hass, ada.id, "next_quest") - full_title = _snapshot_child(coordinator, ada.id).instances[0].title + entry, _coordinator, _client = await _setup_entry(hass, make_entry, payload) + next_entity = _sensor(hass, ADA, "next_quest") + full_title = _snapshot_child( + entry.runtime_data.coordinator, ADA + ).instances[0].title assert len(full_title) > 255 state = next_entity.native_value assert len(state) <= 255 @@ -473,19 +501,21 @@ async def test_household_rollups_sum_the_active_children( hass, make_entry ) -> None: """Household due/completed equal the sum of the per-child sensors; - the cycle-day sensor carries the coordinator's answer.""" - import datetime as _dt - - from custom_components.nestquest.dao_presence import ( - PresenceSchedulesDao, + the cycle-day sensor carries the coordinator's answer (precomputed + by the API, which resolves the schedule engine server-side).""" + payload = _payload( + [ + _child( + ADA, + "Ada", + [_instance(7, ADA, state="completed", completed_at=f"{_today_iso()}T07:55:00+00:00", on_time=True)], + ), + _child(BO, "Bo", [_instance(8, BO)]), + _child(CORY, "Cory"), + ] ) - - entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( - hass, make_entry - ) - database = entry.runtime_data.database - await _complete_first_instance(database, coordinator, ada) - await coordinator.async_refresh() + payload["cycle_day"] = 4 + entry, coordinator, _client = await _setup_entry(hass, make_entry, payload) household_due = hass.entities["nestquest_household_quests_due_today"] household_completed = hass.entities[ @@ -493,12 +523,12 @@ async def test_household_rollups_sum_the_active_children( ] cycle_day_entity = hass.entities["nestquest_cycle_day"] per_child_due = [ - _sensor(hass, child.id, "quests_due_today").native_value - for child in (ada, bo, cory) + _sensor(hass, child_id, "quests_due_today").native_value + for child_id in (ADA, BO, CORY) ] per_child_completed = [ - _sensor(hass, child.id, "quests_completed_today").native_value - for child in (ada, bo, cory) + _sensor(hass, child_id, "quests_completed_today").native_value + for child_id in (ADA, BO, CORY) ] assert household_due.native_value == sum(per_child_due) assert household_completed.native_value == sum(per_child_completed) @@ -507,16 +537,5 @@ async def test_household_rollups_sum_the_active_children( "NestQuest household quests completed today" ) assert cycle_day_entity.name == "NestQuest cycle day" - assert cycle_day_entity.native_value == 0, "no schedule anywhere" - - # A schedule on the FIRST active child drives cycle day. - today = _dt.date.today() - await PresenceSchedulesDao(database).upsert_by_child( - ada.id, 2, (today - _dt.timedelta(days=3)).isoformat(), - "0,1,2,3,4,5,6|", - ) - await coordinator.async_refresh() assert cycle_day_entity.native_value == 4 - assert cycle_day_entity.extra_state_attributes["today"] == ( - today.isoformat() - ) + assert cycle_day_entity.extra_state_attributes["today"] == _today_iso() diff --git a/tests/test_startup_db.py b/tests/test_startup_db.py deleted file mode 100644 index 323c7c3..0000000 --- a/tests/test_startup_db.py +++ /dev/null @@ -1,472 +0,0 @@ -"""Tests for startup handling of missing, valid, and corrupt databases.""" -from __future__ import annotations - -import asyncio -import hashlib -import sqlite3 -from unittest.mock import AsyncMock, MagicMock - -import pytest -from conftest import make_config_entry -from homeassistant.exceptions import ConfigEntryNotReady - -from custom_components.nestquest import ( - _async_open_database, - _is_corruption_error, - async_setup_entry, - async_unload_entry, -) -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import ( - MIGRATIONS, - VERSION_TABLE, - apply_migrations, -) -from tests.test_lifecycle import _wire - - -def _run(coro): - return asyncio.new_event_loop().run_until_complete(coro) - - -def _make_hass_mock(): - hass = MagicMock() - hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) - return hass - - -def _open_db(path) -> NestQuestDatabase: - database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) - _run(database.open(path)) - return database - - -async def _avalid_database(path) -> None: - """Create a valid, migrated NestQuest database file (async, on the - running loop, for use inside pytest-asyncio tests).""" - database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) - await database.open(path) - try: - await apply_migrations(database) - finally: - await database.close() - - -def _valid_database(path) -> None: - """Create a valid, migrated NestQuest database file at ``path``.""" - database = _open_db(path) - try: - _run(apply_migrations(database)) - finally: - _run(database.close()) - - -def _truncated_database(path) -> None: - """Create a deliberately truncated (corrupt) database file. - - A valid database is written, closed, then cut short mid-header: - opening it reports 'file is not a database' — exactly what an - interrupted write or failing disk leaves behind. - """ - _valid_database(path) - data = path.read_bytes() - assert len(data) > 64, "valid database must exist before truncation" - path.write_bytes(data[: len(data) // 2]) - - -# --------------------------------------------------------------------------- -# Corruption classification -# --------------------------------------------------------------------------- - - -def test_is_corruption_error_matches_sqlite_spellings() -> None: - assert _is_corruption_error( - sqlite3.DatabaseError("file is not a database") - ) - assert _is_corruption_error( - sqlite3.DatabaseError("file is not a database".upper()) - ) - assert _is_corruption_error( - sqlite3.DatabaseError("file is encrypted or malformed") - ) - assert _is_corruption_error( - sqlite3.OperationalError("file is not a database") - ) - - -def test_is_corruption_error_rejects_other_failures() -> None: - assert not _is_corruption_error( - sqlite3.OperationalError("database is locked") - ) - assert not _is_corruption_error( - sqlite3.IntegrityError("NOT NULL constraint failed") - ) - assert not _is_corruption_error(RuntimeError("boom")) - assert not _is_corruption_error(ValueError("nope")) - - -# --------------------------------------------------------------------------- -# Startup: three cases through _async_open_database -# --------------------------------------------------------------------------- - - -def test_missing_file_opens_fresh(tmp_path) -> None: - db_path = tmp_path / "nestquest.db" - assert not db_path.exists() - database = _run(_async_open_database(_make_hass_mock(), db_path)) - try: - # The file was created fresh and migrations stamped the - # latest schema version. - assert db_path.exists() - row = _run( - database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") - ) - assert row == (len(MIGRATIONS),) - finally: - _run(database.close()) - - -def test_zero_byte_existing_file_is_corrupt_and_untouched(tmp_path) -> None: - """A zero-byte file is neither missing nor valid: it is corrupt. - - The leave-untouched contract applies — startup refuses (retryable) - rather than silently initializing over whatever interrupted write - left the empty file, and the file stays exactly zero bytes. - """ - db_path = tmp_path / "nestquest.db" - db_path.write_bytes(b"") - with pytest.raises(ConfigEntryNotReady) as excinfo: - _run(_async_open_database(_make_hass_mock(), db_path)) - assert db_path.stat().st_size == 0 - assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) - # No sidecars appeared from the refusal path. - assert not (tmp_path / "nestquest.db-wal").exists() - assert not (tmp_path / "nestquest.db-shm").exists() - - -def test_valid_file_opens_and_migrations_noop(tmp_path) -> None: - db_path = tmp_path / "nestquest.db" - _valid_database(db_path) - digest_before = hashlib.sha256(db_path.read_bytes()).hexdigest() - database = _run(_async_open_database(_make_hass_mock(), db_path)) - try: - row = _run( - database.fetch_one(f"SELECT version FROM {VERSION_TABLE}") - ) - assert row == (len(MIGRATIONS),) - finally: - _run(database.close()) - # Opening a valid file must not rewrite it (the no-op path). - assert ( - hashlib.sha256(db_path.read_bytes()).hexdigest() == digest_before - ) - - -def test_corrupt_file_raises_not_ready_and_leaves_file_untouched( - tmp_path, caplog -) -> None: - db_path = tmp_path / "nestquest.db" - _truncated_database(db_path) - corrupt_bytes = db_path.read_bytes() - digest = hashlib.sha256(corrupt_bytes).hexdigest() - - with pytest.raises(ConfigEntryNotReady) as excinfo: - _run(_async_open_database(_make_hass_mock(), db_path)) - - # The file is byte-identical: the owner can restore/inspect it. - assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest - assert len(db_path.read_bytes()) == len(corrupt_bytes) - # The original error is preserved and the log names the path. - assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) - assert any( - "corrupt" in record.getMessage().lower() - and str(db_path) in record.getMessage() - for record in caplog.records - if record.levelname == "ERROR" - ), "the error log must name the corrupt path" - - -def test_corrupt_file_raises_not_ready_never_raw_error(tmp_path) -> None: - """The corrupt case must surface as ConfigEntryNotReady, not crash - setup with a raw sqlite3.DatabaseError — and it must ALWAYS raise - (returning normally would mean corruption was silently accepted).""" - db_path = tmp_path / "nestquest.db" - _truncated_database(db_path) - with pytest.raises(ConfigEntryNotReady) as excinfo: - _run(_async_open_database(_make_hass_mock(), db_path)) - assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) - - -def test_migration_stage_corruption_leaves_db_and_sidecars_untouched( - tmp_path, monkeypatch -) -> None: - """A valid header with a corrupt LATER page must be rejected by the - whole-file read-only preflight BEFORE the read-write open can - switch the journal mode to WAL. - - The fixture is deliberately a ROLLBACK-JOURNAL file (created with - plain sqlite3, default journal_mode=delete, NOT via the wrapper): - that is the case where the WAL transition actually rewrites the - header — a WAL-mode fixture would mask the write under test. The - open() spy proves the read-write open was never reached. - """ - db_path = tmp_path / "nestquest.db" - conn = sqlite3.connect(db_path) - try: - conn.execute("PRAGMA journal_mode=DELETE") - conn.execute( - "CREATE TABLE filler (id INTEGER PRIMARY KEY, " - "payload TEXT NOT NULL)" - ) - conn.executemany( - "INSERT INTO filler (payload) VALUES (?)", - [(f"row-{index}" * 8,) for index in range(200)], - ) - conn.commit() - finally: - conn.close() - data = bytearray(db_path.read_bytes()) - assert conn.execute is not None - assert len(data) > 4200, "fixture must span more than one page" - # Corrupt a page well past the 100-byte header (offset 4096, the - # second page region): the header itself stays valid, so the - # read-write open succeeds and only the integrity probe/migrations - # discover damage. - for offset in range(4096, 4128): - data[offset] = 0xFF - db_path.write_bytes(bytes(data)) - corrupt_bytes = db_path.read_bytes() - digest = hashlib.sha256(corrupt_bytes).hexdigest() - - # Track whether the read-write open is reached at all: the whole - # preflight must reject this corruption BEFORE NestQuestDatabase - # .open() is ever called, which is the only way the file can stay - # byte-identical (the open persists WAL, rewriting the header). - open_calls: list[str] = [] - original_open = NestQuestDatabase.open - - async def _spy_open(self, path): - open_calls.append(str(path)) - return await original_open(self, path) - - monkeypatch.setattr(NestQuestDatabase, "open", _spy_open) - - with pytest.raises(ConfigEntryNotReady) as excinfo: - _run(_async_open_database(_make_hass_mock(), db_path)) - assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) - - # The corruption was caught at the preflight: the read-write open - # (and with it the WAL header rewrite) was never attempted. - assert open_calls == [], ( - "whole-file preflight must reject corruption before the " - "read-write open can transition the journal mode" - ) - - # The main file is byte-identical to the corrupt state we handed in. - assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest - # No WAL/SHM sidecars left behind (WAL mode was never persisted, - # or they were cleaned up on close). - assert not (tmp_path / "nestquest.db-wal").exists() - assert not (tmp_path / "nestquest.db-shm").exists() - - -def test_wal_mode_corruption_leaves_file_and_sidecars_untouched( - tmp_path, monkeypatch -) -> None: - """A corrupt WAL-HEADER file must be rejected without creating - -shm sidecars from the read-only preflight itself. - - The immutable=1 probe URI is what guarantees a read-only - connection creates no sidecars even for a WAL-header database; the - sidecar assertions after the call prove it. - """ - db_path = tmp_path / "nestquest.db" - # WAL fixture: created via the wrapper (journal_mode=WAL persists). - _valid_database(db_path) - data = bytearray(db_path.read_bytes()) - # Corrupt the WAL header flag region: bytes 18/19 hold the - # read/write version numbers (2 = WAL). Flip them to garbage so - # the file claims a broken journal mode. - assert len(data) > 64 - data[18] = 0x99 - data[19] = 0x99 - db_path.write_bytes(bytes(data)) - corrupt_bytes = db_path.read_bytes() - digest = hashlib.sha256(corrupt_bytes).hexdigest() - - with pytest.raises(ConfigEntryNotReady): - _run(_async_open_database(_make_hass_mock(), db_path)) - - # Main file byte-identical; no -shm appeared from the probe. - assert hashlib.sha256(db_path.read_bytes()).hexdigest() == digest - assert not (tmp_path / "nestquest.db-shm").exists() - - -def test_open_failure_closes_connection_when_opened( - tmp_path, monkeypatch -) -> None: - """A failure DURING MIGRATIONS closes the opened connection; a - failure inside open() itself has nothing to close (the wrapper - never adopted a connection), so close must not be called there. - """ - db_path = tmp_path / "nestquest.db" - _truncated_database(db_path) - - close_calls: list[bool] = [] - original_close = NestQuestDatabase.close - - async def _spy_close(self): - close_calls.append(True) - return await original_close(self) - - monkeypatch.setattr(NestQuestDatabase, "close", _spy_close) - with pytest.raises(ConfigEntryNotReady): - _run(_async_open_database(_make_hass_mock(), db_path)) - # Corruption hit at PRAGMA time inside open(): no connection was - # adopted, so there is nothing to close and close() must NOT have - # been called (calling close on a never-opened wrapper would be - # harmless but the helper deliberately skips it). - assert close_calls == [] - - # Contrast: a failure DURING migrations (file opens fine) closes. - close_calls.clear() - valid_path = tmp_path / "migrations-fail.db" - _valid_database(valid_path) - # _valid_database's own close went through the spy; only the - # failure path's close counts from here. - close_calls.clear() - from custom_components.nestquest import migrations as migrations_mod - from custom_components.nestquest.db import NestQuestDatabase as _DB - - async def _failing(database, migrations=None): - raise sqlite3.DatabaseError("database disk image is malformed") - - monkeypatch.setattr(migrations_mod, "apply_migrations", _failing) - monkeypatch.setattr( - "custom_components.nestquest.apply_migrations", _failing - ) - with pytest.raises(ConfigEntryNotReady) as excinfo: - _run(_async_open_database(_make_hass_mock(), valid_path)) - # Exactly one close: the migrations-failure cleanup path. (The - # spy also sees the wrapper's own idempotent close if it were - # double-called — it must not be.) - assert close_calls == [True], ( - "an open connection must be closed before the error propagates" - ) - assert isinstance(excinfo.value.__cause__, sqlite3.DatabaseError) - - -# --------------------------------------------------------------------------- -# Startup through async_setup_entry (the real wiring) -# --------------------------------------------------------------------------- - - -def _make_setup(hass, registry): - def _setup(entry): - return async_setup_entry(hass, entry) - - return _setup - - -async def _setup_entry(hass, entry, registry) -> object: - entry = _wire(entry, registry) - await async_setup_entry(hass, entry) - return entry - - -async def test_setup_with_corrupt_file_raises_not_ready( - hass, make_entry, monkeypatch, tmp_path, caplog -) -> None: - from custom_components.nestquest.store import async_get_db_path - - db_path = await async_get_db_path(hass) - await _avalid_database(db_path) - data = db_path.read_bytes() - assert len(data) > 64 - db_path.write_bytes(data[: len(data) // 2]) - corrupt_bytes = db_path.read_bytes() - - entry = make_entry() - with pytest.raises(ConfigEntryNotReady): - await _setup_entry(hass, entry, hass.registry) - # File untouched, no runtime data stored. - assert db_path.read_bytes() == corrupt_bytes - assert hass.data["nestquest"].get(entry.entry_id) is None - - -async def test_setup_with_missing_file_creates_and_loads( - hass, make_entry -) -> None: - entry = await _setup_entry(hass, make_entry(), hass.registry) - database = entry.runtime_data.database - try: - row = await database.fetch_one( - f"SELECT version FROM {VERSION_TABLE}" - ) - assert row == (len(MIGRATIONS),) - finally: - await database.close() - assert await async_unload_entry(hass, entry) is True - - -async def test_setup_with_valid_file_preserves_rows( - hass, make_entry -) -> None: - entry_a = await _setup_entry(hass, make_entry(), hass.registry) - database_a = entry_a.runtime_data.database - await database_a.execute( - "INSERT INTO children (display_name, created_at) VALUES (?, ?)", - ("Ada", "2026-09-14T12:00:00+00:00"), - ) - # Unload pops the runtime record AND closes the connection; a - # fresh setup for the same entry_id then reopens the file. - assert await async_unload_entry(hass, entry_a) is True - - entry_b = await _setup_entry(hass, make_entry(), hass.registry) - database_b = entry_b.runtime_data.database - assert database_b is not database_a - try: - row = await database_b.fetch_one("SELECT COUNT(*) FROM children") - assert row == (1,) - finally: - await database_b.close() - assert await async_unload_entry(hass, entry_b) is True - - -# --------------------------------------------------------------------------- -# No code path deletes or overwrites an existing database file -# --------------------------------------------------------------------------- - - -def test_package_never_unlinks_or_truncates_db_files() -> None: - """Guardrail: no code path may delete or overwrite an existing - database file. Scans the package for unlink/rmtree/write on the - DB filename constant or *.db patterns. - - Recursive (``rglob``) so the bundled ``core/`` package (extracted - in task d0b691d8 / 510c1f78) is scanned alongside the integration's - own modules — a non-recursive ``glob`` skips ``core/`` and leaves - the guardrail vacuous against it. - """ - import re - from pathlib import Path - - package = Path( - __import__( - "custom_components.nestquest", fromlist=["__file__"] - ).__file__ - ).parent - pattern = re.compile( - r"(unlink|rmtree|write_bytes|write_text|open\s*\(\s*[^)]*['\"]w)" - r"[^\n]*(nestquest\.db|\.db['\"])|" - r"(nestquest\.db|\.db['\"])[^\n]*" - r"(unlink|rmtree|write_bytes|write_text)", - re.IGNORECASE, - ) - offenders: list[str] = [] - for py in sorted(package.rglob("*.py")): - if pattern.search(py.read_text()): - offenders.append(py.name) - assert offenders == [], ( - f"code paths that could delete/overwrite the database: {offenders}" - ) \ No newline at end of file diff --git a/tests/test_store.py b/tests/test_store.py deleted file mode 100644 index ae773c7..0000000 --- a/tests/test_store.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Tests for store.py of NestQuest.""" -from __future__ import annotations - -import ast -import asyncio -from pathlib import Path -from unittest.mock import AsyncMock, MagicMock - -from custom_components.nestquest import store -from custom_components.nestquest.const import SQLITE_DB_FILENAME - - -def _run(coro): - return asyncio.new_event_loop().run_until_complete(coro) - - -def _make_hass(config_dir: Path, execute: bool = True): - hass = MagicMock() - hass.config.path = MagicMock(return_value=str(config_dir / SQLITE_DB_FILENAME)) - hass.async_add_executor_job = AsyncMock( - side_effect=(lambda fn, *a: fn(*a)) if execute else None - ) - return hass - - -def test_returns_absolute_path_from_config_path(monkeypatch) -> None: - # Path resolution only: the parent-dir mkdir is a side effect of - # core.store and is exercised elsewhere; suppress it here so the - # absolute-path assertion can run against /config (non-writable). - monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None) - hass = _make_hass(Path("/config"), execute=False) - db_path = _run(store.async_get_db_path(hass)) - hass.config.path.assert_called_once_with(SQLITE_DB_FILENAME) - assert db_path == Path("/config") / SQLITE_DB_FILENAME - assert db_path.is_absolute() - - -def test_relative_config_path_is_normalized_to_absolute(tmp_path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - hass = MagicMock() - hass.config.path = MagicMock(return_value="relative-config/" + SQLITE_DB_FILENAME) - hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) - db_path = _run(store.async_get_db_path(hass)) - assert db_path.is_absolute() - assert db_path.name == SQLITE_DB_FILENAME - assert str(db_path).endswith("relative-config/" + SQLITE_DB_FILENAME) - assert db_path.parent.is_dir() - assert (tmp_path / "relative-config").is_dir() - assert not db_path.exists() - - -def test_creates_missing_parent_directory(tmp_path) -> None: - nested = tmp_path / "homeassistant" / "config" - hass = _make_hass(nested) - db_path = _run(store.async_get_db_path(hass)) - assert db_path.parent == nested - assert nested.is_dir() - assert db_path.parent.is_dir() - assert not db_path.exists() - - -def test_two_calls_return_same_path(tmp_path) -> None: - hass = _make_hass(tmp_path) - first = _run(store.async_get_db_path(hass)) - second = _run(store.async_get_db_path(hass)) - assert first == second - assert first.parent.is_dir() - assert not first.exists() - assert not second.exists() - - -def test_existing_directory_does_not_fail(tmp_path) -> None: - nested = tmp_path / "config" - nested.mkdir() - hass = _make_hass(nested) - db_path = _run(store.async_get_db_path(hass)) - assert db_path == nested / SQLITE_DB_FILENAME - assert db_path.parent.is_dir() - assert not db_path.exists() - - -def test_mkdir_runs_off_event_loop(tmp_path, monkeypatch) -> None: - """The parent-directory mkdir runs off the event loop via asyncio.to_thread. - - core.store is Home-Assistant-free, so it schedules the blocking - ``mkdir`` through :func:`asyncio.to_thread` rather than - ``hass.async_add_executor_job``; the integration shim delegates to it - unchanged. The intent is preserved: the mkdir still runs off the - event loop, with ``_ensure_dir`` on the parent directory, and the - directory exists afterwards. - """ - nested = tmp_path / "deep" / "nested" - hass = _make_hass(nested) - to_thread_calls: list = [] - real_to_thread = asyncio.to_thread - - def _trace_to_thread(fn, *args, **kwargs): - to_thread_calls.append((fn, args, kwargs)) - return real_to_thread(fn, *args, **kwargs) - - monkeypatch.setattr(asyncio, "to_thread", _trace_to_thread) - db_path = _run(store.async_get_db_path(hass)) - assert nested.is_dir() - assert len(to_thread_calls) == 1, ( - f"expected one asyncio.to_thread call, got {len(to_thread_calls)}" - ) - fn, fn_args, fn_kwargs = to_thread_calls[0] - assert callable(fn) - assert fn is store._ensure_dir - assert fn_args == (nested,) - assert fn_kwargs == {} - assert db_path.parent == nested - assert db_path.parent.is_dir() - assert not db_path.exists() - - -def test_executor_helper_creates_directory(tmp_path) -> None: - nested = tmp_path / "executor" / "made" - store._ensure_dir(nested) - assert nested.is_dir() - store._ensure_dir(nested) - assert nested.is_dir() - - -def test_no_sqlite3_import_outside_db_module() -> None: - """sqlite3 is banned package-wide except two justified modules. - - db.py is the connection wrapper (all statement execution lives - there). __init__.py imports sqlite3 ONLY for its exception types - to classify corruption on startup (done-condition: corrupt file -> - ConfigEntryNotReady); it never opens a connection or executes - SQL — that is asserted separately below. - """ - pkg_dir = Path(store.__file__).parent - py_files = list(pkg_dir.glob("*.py")) - assert py_files - allowed = {"db.py", "__init__.py"} - for py_file in py_files: - if py_file.name in allowed: - continue - tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - assert alias.name != "sqlite3", ( - f"Found sqlite3 import in {py_file.name}:{node.lineno}" - ) - elif isinstance(node, ast.ImportFrom) and node.module == "sqlite3": - raise AssertionError( - f"Found sqlite3 import in {py_file.name}:{node.lineno}" - ) - - -def test_init_module_never_opens_or_executes_sqlite() -> None: - """The __init__.py sqlite3 usage is limited to (a) exception typing - for corruption classification and (b) the ONE read-only preflight - connection (mode=ro URI, SELECT-only) that detects corruption in an - existing file before the real read-write open. No read-write - connect, no cursor writes, no non-SELECT statements may appear. - """ - import re - from pathlib import Path - - init_path = ( - Path(store.__file__).parent / "__init__.py" - ).read_text(encoding="utf-8") - # The preflight connect must be the URI read-only form, and only - # SELECT statements and the integrity_check PRAGMA may run there. - assert "sqlite3.connect(uri, uri=True)" in init_path - assert "?mode=ro" in init_path - assert 'PRAGMA integrity_check' in init_path - forbidden = re.compile( - r"sqlite3\s*\.\s*connect(?!\(\s*uri\s*,)" - r"|\bconn(ection)?\s*\.\s*execute\s*\(\s*[\"'](?!SELECT\s|PRAGMA\s+integrity_check)" - r"|\bexecutemany\b|\bexecutescript\b", - re.IGNORECASE, - ) - assert forbidden.search(init_path) is None, ( - "__init__.py may use sqlite3 only for exception typing and the " - "read-only corruption preflight (SELECT + integrity_check)" - ) - - -def test_no_file_creation_calls_in_store() -> None: - source = Path(store.__file__).read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(store.__file__)) - for node in ast.walk(tree): - if isinstance(node, ast.Call): - func = node.func - if isinstance(func, ast.Name) and func.id == "open": - raise AssertionError( - f"Found open() call in store.py:{node.lineno}" - ) - if ( - isinstance(func, ast.Attribute) - and func.attr == "touch" - ): - raise AssertionError( - f"Found touch() call in store.py:{node.lineno}" - ) - assert "touch(" not in source - assert "open(" not in source - - -def test_container_style_path_resolves(monkeypatch) -> None: - monkeypatch.setattr("custom_components.nestquest.core.store._ensure_dir", lambda path: None) - hass = _make_hass(Path("/config"), execute=False) - assert _run(store.async_get_db_path(hass)) == Path("/config") / SQLITE_DB_FILENAME - - -def test_core_style_path_resolves(tmp_path) -> None: - config_dir = tmp_path / ".homeassistant" - hass = _make_hass(config_dir) - assert _run(store.async_get_db_path(hass)) == config_dir / SQLITE_DB_FILENAME - assert (config_dir / SQLITE_DB_FILENAME).parent.is_dir() \ No newline at end of file diff --git a/tests/test_sweep.py b/tests/test_sweep.py deleted file mode 100644 index c76ac81..0000000 --- a/tests/test_sweep.py +++ /dev/null @@ -1,259 +0,0 @@ -"""Tests for the nightly missed-quest sweep (Feature 11, task 1).""" -from __future__ import annotations - -import datetime -import re - -from conftest import wire_entry_to_registry - -from custom_components.nestquest import async_setup_entry -from custom_components.nestquest.children import create_child, list_children -from custom_components.nestquest.completion import ( - complete_instance, - uncomplete_instance, -) -from custom_components.nestquest.const import EVENT_QUEST_MISSED -from custom_components.nestquest.dao_instances import ( - CompletionEventsDao, - QuestInstancesDao, -) -from custom_components.nestquest.dao_meta import MetaStateDao -from custom_components.nestquest.materialize import materialize -from custom_components.nestquest.quest_definitions import ( - create_quest_definition, -) -from custom_components.nestquest.recurrence import ScheduleRule -from custom_components.nestquest.sweep import ( - SWEEP_WATERMARK_KEY, - run_missed_sweep, -) - -_TIMESTAMP = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+00:00$") - - -async def _seed(hass, make_entry) -> tuple: - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - ada = await create_child(database, "Ada") - bo = await create_child(database, "Bo") - today = datetime.date.today() - await create_quest_definition( - database, - "Brush teeth", - ScheduleRule.from_dict( - {"rule_type": "daily", "start_date": today.isoformat()} - ), - [ada.id, bo.id], - ["morning"], - ) - end = (today + datetime.timedelta(days=3)).isoformat() - await materialize(database, today.isoformat(), end, today=today) - return entry, ada, bo - - -async def test_sweep_fires_one_event_per_past_due_open_instance( - hass, make_entry -) -> None: - entry, ada, bo = await _seed(hass, make_entry) - database = entry.runtime_data.database - today = datetime.date.today() - tomorrow = today + datetime.timedelta(days=1) - - fired = await run_missed_sweep(hass, database, today=tomorrow) - assert fired == 2, "one per child for today's now-past-due instance" - - events = hass.bus.fired(EVENT_QUEST_MISSED) - assert len(events) == 2 - payload = events[0] - assert payload["child_name"] in ("Ada", "Bo") - assert payload["quest_title"] == "Brush teeth" - assert payload["window"] == "morning" - assert payload["due_date"] == today.isoformat() - assert payload["due_time"] is None - assert _TIMESTAMP.match(payload["occurred_at"]) - assert {payload["child_id"] for payload in events} == {ada.id, bo.id} - # The sweep is read-only over the domain tables. - assert await _count_events(database) == 0 - - -async def test_sweep_is_idempotent_same_night(hass, make_entry) -> None: - entry, _ada, _bo = await _seed(hass, make_entry) - database = entry.runtime_data.database - tomorrow = datetime.date.today() + datetime.timedelta(days=1) - - first = await run_missed_sweep(hass, database, today=tomorrow) - second = await run_missed_sweep(hass, database, today=tomorrow) - assert first == 2 - assert second == 0, "the watermark makes a rerun fire nothing" - assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2 - assert await MetaStateDao(database).get(SWEEP_WATERMARK_KEY) == ( - tomorrow.isoformat() - ) - - -async def test_sweep_only_fires_new_instances_after_watermark( - hass, make_entry -) -> None: - entry, ada, _bo = await _seed(hass, make_entry) - database = entry.runtime_data.database - today = datetime.date.today() - tomorrow = today + datetime.timedelta(days=1) - day_after = today + datetime.timedelta(days=2) - - # First sweep one day ahead: today's instances fire. - fired = await run_missed_sweep(hass, database, today=tomorrow) - assert fired == 2 - first_events = hass.bus.fired(EVENT_QUEST_MISSED) - assert all( - payload["due_date"] == today.isoformat() - for payload in first_events - ) - - # One more day passes: only tomorrow's instances fire now. - fired = await run_missed_sweep(hass, database, today=day_after) - assert fired == 2 - all_events = hass.bus.fired(EVENT_QUEST_MISSED) - assert len(all_events) == 4 - assert all( - payload["due_date"] == tomorrow.isoformat() - for payload in all_events[2:] - ) - - -async def test_sweep_never_reports_completed_or_reversed_instances( - hass, make_entry -) -> None: - entry, ada, bo = await _seed(hass, make_entry) - database = entry.runtime_data.database - today = datetime.date.today() - tomorrow = today + datetime.timedelta(days=1) - - instances_ada = await QuestInstancesDao(database).list_by_date_range( - ada.id, today.isoformat(), today.isoformat() - ) - instances_bo = await QuestInstancesDao(database).list_by_date_range( - bo.id, today.isoformat(), today.isoformat() - ) - # Ada completed hers; Bo completed his then reversed it. - await complete_instance( - database, - instances_ada[0].id, - actor_source="panel", - actor_child_id=ada.id, - ) - await complete_instance( - database, - instances_bo[0].id, - actor_source="panel", - actor_child_id=bo.id, - ) - await uncomplete_instance( - database, - instances_bo[0].id, - actor_source="user", - actor_user_id="admin-1", - ) - - fired = await run_missed_sweep(hass, database, today=tomorrow) - assert fired == 0, ( - "no-completion-event rule excludes both the completed and the " - "reversed instance (append-only history keeps both)" - ) - assert hass.bus.fired(EVENT_QUEST_MISSED) == [] - - -async def test_sweep_skips_instance_completed_after_sweep( - hass, make_entry -) -> None: - """A missed instance completed later is never re-announced; the - late completion still lands in the append-only log.""" - entry, ada, _bo = await _seed(hass, make_entry) - database = entry.runtime_data.database - today = datetime.date.today() - tomorrow = today + datetime.timedelta(days=1) - day3 = today + datetime.timedelta(days=2) - - await run_missed_sweep(hass, database, today=tomorrow) - instance = ( - await QuestInstancesDao(database).list_by_date_range( - ada.id, today.isoformat(), today.isoformat() - ) - )[0] - await complete_instance( - database, - instance.id, - actor_source="panel", - actor_child_id=ada.id, - ) - fired = await run_missed_sweep(hass, database, today=day3) - assert fired == 0 or all( - payload["instance_id"] != instance.id - for payload in hass.bus.fired(EVENT_QUEST_MISSED)[2:] - ) - - -async def test_rollover_listener_runs_sweep_after_materialization( - hass, make_entry, monkeypatch -) -> None: - import custom_components.nestquest as nestquest - - calls = [] - - async def _record_sweep(hass_arg, database, **kwargs): - calls.append("sweep") - - monkeypatch.setattr(nestquest, "run_missed_sweep", _record_sweep) - - entry = wire_entry_to_registry( - make_entry(options={"day_rollover_time": "00:00"}), hass.registry - ) - assert await async_setup_entry(hass, entry) is True - assert calls == ["sweep"], "startup runs the sweep once" - - await hass.time_change.fire() - assert calls == ["sweep", "sweep"], "the rollover listener sweeps too" - - -async def test_v6_database_migrates_to_v7_meta_state( - hass, make_entry -) -> None: - """A database stamped at version 6 gains the meta_state table and - version 7 on the next startup; existing rows are untouched.""" - entry = wire_entry_to_registry(make_entry(), hass.registry) - assert await async_setup_entry(hass, entry) is True - database = entry.runtime_data.database - version = await database.fetch_one( - "SELECT version FROM nestquest_schema_version" - ) - assert version == (7,) - tables = await database.fetch_all( - "SELECT name FROM sqlite_master WHERE type='table' " - "AND name='nestquest_meta_state'" - ) - assert tables, "migration 7 created the meta_state table" - - -async def _count_events(database) -> int: - rows = await database.fetch_all("SELECT COUNT(*) FROM completion_events") - return rows[0][0] - - -async def test_overlapping_sweeps_never_double_announce( - hass, make_entry -) -> None: - """A startup sweep and a rollover sweep racing on the same night - queue on the sweep lock: the winner announces, the loser re-reads - the watermark inside the lock and fires nothing.""" - import asyncio - - entry, _ada, _bo = await _seed(hass, make_entry) - database = entry.runtime_data.database - tomorrow = datetime.date.today() + datetime.timedelta(days=1) - - first, second = await asyncio.gather( - run_missed_sweep(hass, database, today=tomorrow), - run_missed_sweep(hass, database, today=tomorrow), - ) - assert first + second == 2, "each instance announced exactly once" - assert len(hass.bus.fired(EVENT_QUEST_MISSED)) == 2 diff --git a/tests/test_timezones.py b/tests/test_timezones.py deleted file mode 100644 index 0d1dce5..0000000 --- a/tests/test_timezones.py +++ /dev/null @@ -1,355 +0,0 @@ -"""Timezone tests for the single HA-local date source in the generation path. - -These pin the done-condition that every "today" read in the generation path -derives from ``hass.config.time_zone`` — not the host clock and not UTC — -and that the rolling horizon ``[today, today + DEFAULT_HORIZON_DAYS]`` and the -no-past guard resolve the correct local dates around midnight and across the -daylight-saving transitions. -""" -from __future__ import annotations - -import asyncio -import datetime -from types import SimpleNamespace -from zoneinfo import ZoneInfo - -from conftest import freeze_nestquest_clock -from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS -from custom_components.nestquest.core.settings import NestQuestSettings -from custom_components.nestquest.dao_children import ChildrenDao -from custom_components.nestquest.dao_instances import QuestInstancesDao -from custom_components.nestquest.db import NestQuestDatabase -from custom_components.nestquest.migrations import apply_migrations -from custom_components.nestquest.quest_definitions import ( - assign_child, - create_quest_definition, - edit_quest_definition, -) -from custom_components.nestquest.recurrence import RuleType, ScheduleRule - -NOW = "2026-09-14T12:00:00+00:00" -NEW_YORK = "America/New_York" - - -def _make_hass_mock(): - from unittest.mock import AsyncMock, MagicMock - - hass = MagicMock() - hass.async_add_executor_job = AsyncMock(side_effect=(lambda fn, *a: fn(*a))) - return hass - - -async def _prepare(path): - database = NestQuestDatabase(_make_hass_mock().async_add_executor_job) - await database.open(path) - await apply_migrations(database) - return database - - -def _run(coro): - return asyncio.new_event_loop().run_until_complete(coro) - - -def _freeze_clock( - monkeypatch, instant: datetime.datetime -) -> tuple[datetime.datetime, list]: - """Freeze the integration's ``datetime`` clock to a fixed aware instant. - - Thin wrapper over :func:`conftest.freeze_nestquest_clock` kept for the - ``(frozen, requested)`` return shape the timezone assertions read. - """ - return freeze_nestquest_clock(monkeypatch, instant) - - -def _assert_requested_ha_timezone(requested, time_zone: str) -> None: - """Assert ``now`` was called with (only) the configured HA time zone. - - This is what turns the DST tests into a real proof of the HA-local date - source: if the generation path regressed to ``now(timezone.utc)``, its - requested tz would be UTC and this fails. - """ - assert requested, "the generation path never called now(tz)" - assert all(tz == ZoneInfo(time_zone) for tz in requested) - - -def _tz_hass(time_zone: str): - """A minimal hass exposing only the ``config.time_zone`` the walk reads.""" - return SimpleNamespace(config=SimpleNamespace(time_zone=time_zone)) - - -async def _seed_daily(database, start_iso: str) -> int: - """One always-present child + a daily definition starting ``start_iso``.""" - child = await ChildrenDao(database).create("Ada", NOW) - await create_quest_definition( - database, - "Daily chore", - ScheduleRule(rule_type=RuleType.DAILY, start_date=start_iso), - [child.id], - ["morning"], - ) - return child.id - - -def _local_date(instant_utc: datetime.datetime, time_zone: str) -> datetime.date: - return instant_utc.astimezone(ZoneInfo(time_zone)).date() - - -def _assert_contiguous_horizon(records, local_today: datetime.date) -> None: - """The horizon is exactly today..today+horizon, no off-by-one, no gap.""" - expected = { - (local_today + datetime.timedelta(days=offset)).isoformat() - for offset in range(DEFAULT_HORIZON_DAYS + 1) - } - assert {r.due_date for r in records} == expected - assert len(records) == DEFAULT_HORIZON_DAYS + 1 - - -def test_horizon_uses_newyork_local_date_after_local_midnight( - tmp_path, monkeypatch -) -> None: - """A run just after New York local midnight uses the local date. - - The frozen instant is 00:05 New York local time on 2026-03-10 (EDT, so - 04:05 UTC). The horizon must start on 2026-03-10 — the local calendar - date — not any host/UTC-derived value. - """ - instant = datetime.datetime( - 2026, 3, 10, 0, 5, tzinfo=ZoneInfo(NEW_YORK) - ) - local_today = instant.date() - - async def _body(database): - hass = _tz_hass(NEW_YORK) - _frozen, requested = _freeze_clock(monkeypatch, instant) - import custom_components.nestquest as nestquest - - child_id = await _seed_daily(database, local_today.isoformat()) - await nestquest._run_horizon_materialization( - hass, database, NestQuestSettings() - ) - records = await QuestInstancesDao(database).list_by_date_range( - child_id, - local_today.isoformat(), - (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), - ) - _assert_contiguous_horizon(records, local_today) - _assert_requested_ha_timezone(requested, NEW_YORK) - return len(records) - - async def _main(): - database = await _prepare(tmp_path / "tz-midnight.db") - try: - return await _body(database) - finally: - await database.close() - - assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1 - - -def test_horizon_uses_local_date_not_utc_when_host_ahead( - tmp_path, monkeypatch -) -> None: - """The walk's "today" is HA-local, never the UTC/system calendar date. - - FROZEN at 2026-01-15 03:00 UTC: the UTC calendar date is 2026-01-15, but - New York local is still 2026-01-14 22:00 EST. The horizon must start on - 2026-01-14 (local), proving the UTC date did not win. - """ - instant = datetime.datetime(2026, 1, 15, 3, 0, tzinfo=datetime.timezone.utc) - local_today = _local_date(instant, NEW_YORK) - assert local_today == datetime.date(2026, 1, 14) - assert instant.date() == datetime.date(2026, 1, 15) - - async def _body(database): - hass = _tz_hass(NEW_YORK) - _frozen, requested = _freeze_clock(monkeypatch, instant) - import custom_components.nestquest as nestquest - - child_id = await _seed_daily(database, local_today.isoformat()) - await nestquest._run_horizon_materialization( - hass, database, NestQuestSettings() - ) - records = await QuestInstancesDao(database).list_by_date_range( - child_id, - local_today.isoformat(), - (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), - ) - _assert_contiguous_horizon(records, local_today) - _assert_requested_ha_timezone(requested, NEW_YORK) - return len(records) - - async def _main(): - database = await _prepare(tmp_path / "tz-host-ahead.db") - try: - return await _body(database) - finally: - await database.close() - - assert _run(_main()) == DEFAULT_HORIZON_DAYS + 1 - - -def test_horizon_resolves_spring_forward_day_without_off_by_one( - tmp_path, monkeypatch -) -> None: - """Across the 2026-03-08 spring-forward, today and the horizon stay local. - - Before the jump ("01:30 EST") and after it ("03:30 EDT") both resolve to - 2026-03-08 local — the lost hour must not skip or repeat a calendar day — - and the full horizon remains 15 contiguous days. - """ - zone = ZoneInfo(NEW_YORK) - before = datetime.datetime(2026, 3, 8, 1, 30, tzinfo=zone) # EST - after = datetime.datetime(2026, 3, 8, 3, 30, tzinfo=zone) # EDT - assert before.date() == after.date() == datetime.date(2026, 3, 8) - - async def _body(database): - hass = _tz_hass(NEW_YORK) - import custom_components.nestquest as nestquest - - requested_batches: list[list] = [] - for instant in (before, after): - _frozen, requested = _freeze_clock(monkeypatch, instant) - requested_batches.append(requested) - local_today = instant.date() - child_id = await _seed_daily(database, local_today.isoformat()) - await nestquest._run_horizon_materialization( - hass, database, NestQuestSettings() - ) - records = await QuestInstancesDao(database).list_by_date_range( - child_id, - local_today.isoformat(), - (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), - ) - _assert_contiguous_horizon(records, local_today) - requested_all = [tz for batch in requested_batches for tz in batch] - _assert_requested_ha_timezone(requested_all, NEW_YORK) - return local_today - - async def _main(): - database = await _prepare(tmp_path / "tz-spring-forward.db") - try: - return await _body(database) - finally: - await database.close() - - assert _run(_main()) == datetime.date(2026, 3, 8) - - -def test_horizon_resolves_fall_back_day_without_off_by_one( - tmp_path, monkeypatch -) -> None: - """Across the 2026-11-01 fall-back, today and the horizon stay local. - - The repeated 01:00-02:00 hour must not resolve to the wrong calendar - day: the first 01:30 (``fold=0``, EDT) and the repeated 01:30 - (``fold=1``, EST) are distinct instants yet both resolve to 2026-11-01 - — and both, along with 03:30 EST after the jump, materialize the same - 15 contiguous-day horizon with no off-by-one. - """ - zone = ZoneInfo(NEW_YORK) - first = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=0) # EDT - second = datetime.datetime(2026, 11, 1, 1, 30, tzinfo=zone, fold=1) # EST - after = datetime.datetime(2026, 11, 1, 3, 30, tzinfo=zone) # EST - assert first.date() == second.date() == after.date() == datetime.date(2026, 11, 1) - assert first.utcoffset() != second.utcoffset() # the two repeated-hour folds - - async def _body(database): - hass = _tz_hass(NEW_YORK) - import custom_components.nestquest as nestquest - - requested_batches: list[list] = [] - for instant in (first, second, after): - _frozen, requested = _freeze_clock(monkeypatch, instant) - requested_batches.append(requested) - local_today = instant.date() - child_id = await _seed_daily(database, local_today.isoformat()) - await nestquest._run_horizon_materialization( - hass, database, NestQuestSettings() - ) - records = await QuestInstancesDao(database).list_by_date_range( - child_id, - local_today.isoformat(), - (local_today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat(), - ) - _assert_contiguous_horizon(records, local_today) - requested_all = [tz for batch in requested_batches for tz in batch] - _assert_requested_ha_timezone(requested_all, NEW_YORK) - return local_today - - async def _main(): - database = await _prepare(tmp_path / "tz-fall-back.db") - try: - return await _body(database) - finally: - await database.close() - - assert _run(_main()) == datetime.date(2026, 11, 1) - - -def test_definition_business_regeneration_uses_pinned_today_not_host( - tmp_path, monkeypatch -) -> None: - """``edit_quest_definition``/``assign_child`` anchor regeneration on - the caller-pinned HA-local ``today``, not the host clock. - - The host clock (the ``_today`` fallback) is pinned one day AHEAD of the - HA-local ``today``; the guard would reject the local "today" as past - (and the horizon would skip it) if it consulted the host clock. Both - business operations must regenerate against the pinned date and keep - that first local day, proving the threaded ``today`` wins. - """ - import custom_components.nestquest.core.dao_instances as dao_instances_module - - pinned = datetime.date(2026, 1, 14) - host_today = datetime.date(2026, 1, 15) - monkeypatch.setattr(dao_instances_module, "_today", lambda: host_today) - - async def _body(database): - children = ChildrenDao(database) - dao = QuestInstancesDao(database) - horizon_end = pinned + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) - - a = await children.create("Ada", NOW) - created = await create_quest_definition( - database, - "Daily chore", - ScheduleRule(rule_type=RuleType.DAILY, start_date=pinned.isoformat()), - [a.id], - [("morning", "09:00")], - ) - definition_id = created.definition.id - - # edit anchors on pinned: the horizon starts at the local day the - # host clock calls "past", so its presence proves pinned won. - await edit_quest_definition( - database, - definition_id, - windows=[("morning", "10:15")], - today=pinned, - ) - a_records = await dao.list_by_date_range( - a.id, pinned.isoformat(), horizon_end.isoformat() - ) - assert [r.due_date for r in a_records][0] == pinned.isoformat() - assert {r.due_time for r in a_records} == {"10:15"} - assert len(a_records) == DEFAULT_HORIZON_DAYS + 1 - - # assign anchors on pinned too: the new child's regeneration keeps - # the same pinned-local first day rather than skipping to the host day. - b = await children.create("Bo", NOW) - await assign_child(database, definition_id, b.id, today=pinned) - b_records = await dao.list_by_date_range( - b.id, pinned.isoformat(), horizon_end.isoformat() - ) - assert [r.due_date for r in b_records][0] == pinned.isoformat() - assert len(b_records) == DEFAULT_HORIZON_DAYS + 1 - return definition_id - - async def _main(): - database = await _prepare(tmp_path / "tz-definition-regen.db") - try: - return await _body(database) - finally: - await database.close() - - assert _run(_main()) is not None -- 2.55.0