Compare commits

...
Author SHA1 Message Date
overseer 403dd736b8 Remove the HA admin services and the service permission gate
Since Feature 18 the integration is a client of the NestQuest API
service and the admin surface is the PWA plus the API's admin routes,
so the integration must not keep HA services for admin operations.
Drop every admin service handler and its registration (uncomplete,
create/update/set-active quest definition, presence pattern and
overrides, CSV export, manage_child, regenerate) together with the
Feature 09 permission gate that existed only for them: permissions.py,
service_policy.py and the core SERVICE_POLICY registry, the
regenerate handler plumbing in __init__, the dead payload schemas and
services.yaml entries, and the service-name constants they alone
used. Only the panel completion path (nestquest.complete_quest)
remains, still proxying to the entry's shared API client. The four
automation blueprints are untouched — they only read sensors and
listen to bus events.
2026-09-23 08:21:22 -04:00
dev_agent 684f52ece8 Task d25b875f: Last-good snapshot cache and staleness handling (#170) 2026-09-23 08:08:06 -04:00
dev_agent b0bbb5f8c5 complete_quest proxies to the API (task 47c7c90e) (#169) 2026-09-23 07:52:32 -04:00
dev_agent 84d179336b Task b1ed9c45: SSE subscription re-fires the four HA bus events (#168) 2026-09-23 07:04:39 -04:00
dev_agent fafabeabbe Task 6e70d166: Coordinator polls the snapshot and builds the sensors (#167) 2026-09-23 06:33:44 -04:00
dev_agent 78fd75ec45 Task 37d5e806: API client module for the integration (Feature 18) (#166) 2026-09-23 05:12:27 -04:00
29 changed files with 4065 additions and 2407 deletions
+101 -48
View File
@@ -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,
@@ -26,11 +28,12 @@ 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
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 +245,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]:
@@ -278,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
@@ -322,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)
@@ -337,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:
@@ -439,6 +419,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
@@ -478,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
@@ -490,18 +471,46 @@ 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.
# 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,
database=database,
settings=settings,
api_client=api_client,
update_interval_seconds=entry.options.get(
CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL
),
staleness_seconds=entry.options.get(
CONF_SNAPSHOT_STALENESS, DEFAULT_SNAPSHOT_STALENESS
),
)
await coordinator.async_config_entry_first_refresh()
# 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,
@@ -509,12 +518,32 @@ 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,
)
# 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
@@ -522,6 +551,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,
@@ -548,10 +585,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
@@ -581,6 +627,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
+414
View File
@@ -0,0 +1,414 @@
"""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.
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: <type>`` / ``data: <json>`` 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
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",
"EVENTS_PATH",
"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 SSE event-stream route (Feature 16): each
#: transition arrives as an ``event: <type>`` / ``data: <json>`` 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"
#: 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 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: <type>`` and ``data: <json>`` 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,
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
+302 -38
View File
@@ -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,54 +7,81 @@ 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).
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.)
``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.
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
from zoneinfo import ZoneInfo
import time
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_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
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 +92,131 @@ __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"
)
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.
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
@@ -75,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__(
@@ -82,9 +240,9 @@ class NestQuestCoordinator(DataUpdateCoordinator):
hass: HomeAssistant,
*,
entry_id: str,
database: NestQuestDatabase,
settings: NestQuestSettings,
api_client: NestQuestApiClient,
update_interval_seconds: int | None = None,
staleness_seconds: int | None = None,
) -> None:
interval = update_interval_seconds
if (
@@ -93,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,
@@ -100,9 +271,16 @@ 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()
#: 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
@@ -111,12 +289,98 @@ 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 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 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
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:
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
snapshot = snapshot_from_api_payload(payload)
self._last_good_snapshot = snapshot
self._last_good_monotonic = _monotonic()
return snapshot
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] = {}
+51 -25
View File
@@ -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"
@@ -81,6 +66,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
@@ -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
@@ -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)}"
)
+4 -2
View File
@@ -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.
+98 -3
View File
@@ -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,10 @@ from .const import (
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,
@@ -29,12 +33,15 @@ from .const import (
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
@@ -45,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)
@@ -81,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
@@ -116,6 +160,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
@@ -155,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,
@@ -186,6 +257,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
@@ -255,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
@@ -318,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
@@ -336,6 +423,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:
@@ -357,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),
@@ -365,6 +458,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]
@@ -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
@@ -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
+67 -486
View File
@@ -1,61 +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).
``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_completed, 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:
@@ -65,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,
@@ -150,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)
@@ -241,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)
@@ -253,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:
@@ -301,223 +114,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,
)
await _refresh_entities(runtime)
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:
# 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(
"ordered_ids is required when action is reorder"
"nestquest.complete_quest completes through the API's "
"panel route as the tapped child profile; actor must "
"be 'panel' with actor_child_id"
)
await children_layer.reorder_children(database, ordered_ids)
# 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)
@@ -526,84 +166,25 @@ 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)
+4 -291
View File
@@ -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:
+172
View File
@@ -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)
+6 -1
View File
@@ -18,11 +18,14 @@
"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)",
"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)"
}
}
@@ -30,6 +33,8 @@
"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."
}
@@ -18,11 +18,14 @@
"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)",
"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)"
}
}
@@ -30,6 +33,8 @@
"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."
}
+288 -6
View File
@@ -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,246 @@ 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.
``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.
"""
def __init__(self, hass, entry_id: str) -> None:
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
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
],
}
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():
"""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
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."""
+450
View File
@@ -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, "<html>not json</html>"))
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 == []
+283
View File
@@ -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 == []
+684 -232
View File
File diff suppressed because it is too large Load Diff
+37 -10
View File
@@ -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
+44 -15
View File
@@ -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 (
@@ -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
@@ -154,6 +156,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"])
@@ -169,20 +176,38 @@ 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
# --- 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 +228,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()
@@ -231,6 +256,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)),
+148 -85
View File
@@ -1,12 +1,29 @@
"""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. 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
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
from custom_components.nestquest.completion import uncomplete_instance
from custom_components.nestquest.const import (
CONF_ADMIN_USER_IDS,
DOMAIN,
@@ -15,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
@@ -27,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$")
@@ -49,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",
@@ -86,6 +93,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 +123,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 +149,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 +177,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,36 +193,51 @@ 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
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
@@ -223,6 +268,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 +317,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 +337,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,45 +388,15 @@ 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
)
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:
@@ -538,17 +572,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 +781,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 +834,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 -131
View File
@@ -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")
+224 -8
View File
@@ -15,22 +15,28 @@ 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_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_TOKEN,
DEFAULT_SNAPSHOT_STALENESS,
DEFAULT_UPDATE_INTERVAL,
DEFAULT_DAY_ROLLOVER_TIME,
DEFAULT_HORIZON_DAYS,
@@ -49,11 +55,13 @@ 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,
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,
@@ -62,6 +70,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,
}
@@ -239,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",
@@ -251,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",
@@ -259,6 +271,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 +286,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 +343,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 +818,200 @@ 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"]
# ---------------------------------------------------------------------------
# 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"]
-238
View File
@@ -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
)
-122
View File
@@ -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]
+21 -14
View File
@@ -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
-32
View File
@@ -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}
+40 -472
View File
@@ -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"
)
)
+619
View File
@@ -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