From 78fd75ec45b1d5acc03e151d5bb9ecbf0a030fc7 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 05:12:27 -0400 Subject: [PATCH] Task 37d5e806: API client module for the integration (Feature 18) (#166) --- custom_components/nestquest/api_client.py | 283 +++++++++++ custom_components/nestquest/core/const.py | 26 + custom_components/nestquest/options_flow.py | 43 ++ custom_components/nestquest/strings.json | 5 +- .../nestquest/translations/en.json | 5 +- tests/test_api_client.py | 450 ++++++++++++++++++ tests/test_options_flow.py | 118 ++++- 7 files changed, 920 insertions(+), 10 deletions(-) create mode 100644 custom_components/nestquest/api_client.py create mode 100644 tests/test_api_client.py diff --git a/custom_components/nestquest/api_client.py b/custom_components/nestquest/api_client.py new file mode 100644 index 0000000..1a7ed05 --- /dev/null +++ b/custom_components/nestquest/api_client.py @@ -0,0 +1,283 @@ +"""Panel-plane API client for the NestQuest integration (Feature 18). + +The integration talks to the NestQuest API service (Feature 16) over +HTTP instead of reading the database: this module is the ONE place that +speaks the API's panel plane — ``GET /api/v1/panel/snapshot`` (today's +household snapshot) and ``POST /api/v1/panel/instances/{id}/complete`` +(completion as the tapped child profile) — authenticated with the +static panel service token as a Bearer credential on every request. + +Transport-injected by construction: the constructor takes the base URL, +the panel token, and the HTTP session to use. The session speaks the +aiohttp ``ClientSession`` surface (``request(method, url, headers=..., +json=...)`` returning an async context manager whose response carries +``status`` and ``await text()``), so tests stub the transport with NO +network and NO aiohttp installed; production builds the client through +:func:`client_from_entry`, which hands in Home Assistant's shared +aiohttp session (``homeassistant.helpers.aiohttp_client +.async_get_clientsession``). The module itself imports NEITHER +homeassistant NOR aiohttp at runtime (aiohttp appears only behind +``TYPE_CHECKING`` for annotations), so the client is usable anywhere — +the HA coupling lives in the one production factory. + +Every failure is typed: a transport failure (connection refused, DNS +failure, timeout — the exchange never produced a response) and every +non-2xx HTTP response raise :class:`NestQuestApiError`. The error +carries the HTTP status where there is one (so 401 and 404 are +distinguishable at the call site) and ``None`` when there is not; the +original transport error is chained as ``__cause__`` — nothing is +swallowed. There are NO retries and the client logs nothing: refresh +cadence is the coordinator's concern (the next task), and the panel +token is a secret that must never reach a log line or an error +message — the error texts name the method, path, and status only. +""" +from __future__ import annotations + +import asyncio +import json +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from .const import ( + CONF_API_BASE_URL, + CONF_PANEL_TOKEN, + DEFAULT_API_BASE_URL, + DEFAULT_PANEL_TOKEN, +) + +if TYPE_CHECKING: + import aiohttp + +__all__ = [ + "COMPLETE_INSTANCE_PATH", + "DEFAULT_TIMEOUT_SECONDS", + "NestQuestApiError", + "NestQuestApiClient", + "SNAPSHOT_PATH", + "client_from_entry", + "is_valid_base_url", + "resolve_api_config", +] + +#: The panel plane's snapshot route (Feature 16). +SNAPSHOT_PATH = "/api/v1/panel/snapshot" + +#: The panel plane's completion route template (Feature 16); the +#: ``{instance_id}`` placeholder is filled per call. +COMPLETE_INSTANCE_PATH = "/api/v1/panel/instances/{instance_id}/complete" + +#: Request timeout, in seconds. One attempt, one timeout: a hung API +#: call surfaces as a typed error for the coordinator to report, never +#: a retry loop. +DEFAULT_TIMEOUT_SECONDS = 10.0 + + +class NestQuestApiError(Exception): + """A failed NestQuest API exchange, raised for every failure mode. + + ``status`` carries the HTTP status when a response arrived (a + non-2xx response — 401 for a bad token, 404 for an unknown + instance) and is ``None`` when the exchange failed at the transport + level (connection refused, DNS failure, timeout) — so callers + distinguish the two with ``is_transport_error``. The original + transport error, where there is one, is chained as ``__cause__``. + Messages carry the method, path, and status — never the request + headers, so the panel token can never leak through an error. + """ + + def __init__(self, message: str, *, status: int | None = None) -> None: + super().__init__(message) + self.status = status + + @property + def is_transport_error(self) -> bool: + """True when the exchange failed before any response arrived.""" + return self.status is None + + +def is_valid_base_url(value: object) -> bool: + """Return True when ``value`` is a usable API base URL. + + A full ``http://`` or ``https://`` URL with a host — the exact + shape the options flow validates (form ergonomics) and the client + constructor enforces (fail fast, never build requests against a + scheme-less or host-less string). + """ + if not isinstance(value, str): + return False + candidate = value.strip() + if not candidate: + return False + parsed = urlparse(candidate) + return parsed.scheme in ("http", "https") and bool(parsed.netloc) + + +def resolve_api_config(entry: Any) -> tuple[str, str]: + """Resolve ``(base_url, panel_token)`` off a config entry. + + Precedence mirrors the options flow's stored-values resolution: + ``entry.options`` first (the live options-flow copy), then + ``entry.data`` (the config-flow-era copy), then the const defaults. + The token is a secret: callers must never log the returned pair. + """ + options = getattr(entry, "options", None) or {} + data = getattr(entry, "data", None) or {} + + def _pick(key: str, default: str) -> str: + if key in options: + return options[key] + if key in data: + return data[key] + return default + + return ( + _pick(CONF_API_BASE_URL, DEFAULT_API_BASE_URL), + _pick(CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN), + ) + + +def client_from_entry(hass: Any, entry: Any) -> NestQuestApiClient: + """Build the production client for a config entry. + + Reads the configured base URL and panel token off the entry + (:func:`resolve_api_config`) and hands the client Home Assistant's + SHARED aiohttp session (``homeassistant.helpers.aiohttp_client + .async_get_clientsession``) so panel calls ride HA's one connection + pool. The helper import is deferred into this function on purpose: + the test harness is mock-only and the module must import (and the + client must be stub-transport testable) without homeassistant or + aiohttp installed. + """ + from homeassistant.helpers.aiohttp_client import async_get_clientsession + + base_url, panel_token = resolve_api_config(entry) + return NestQuestApiClient( + base_url, panel_token, async_get_clientsession(hass) + ) + + +class NestQuestApiClient: + """Async client for the NestQuest API service's panel plane. + + ONE instance per config entry (the coordinator task wires it into + the refresh cycle). The client is deliberately thin: it adds the + Bearer credential and the base URL, enforces one timeout per + request, and turns every failure into :class:`NestQuestApiError` — + no business logic, no retries, no caching. + """ + + def __init__( + self, + base_url: str, + panel_token: str, + session: aiohttp.ClientSession, + *, + timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, + ) -> None: + """Store the connection settings; the session is injected. + + ``session`` is an aiohttp ``ClientSession`` in production and a + stub transport in tests. ``base_url`` must be a full http(s) + URL and ``panel_token`` must be non-empty — a client built + without either can only fail later at request time with a + less-explicable error, so construction fails fast instead. + """ + if not is_valid_base_url(base_url): + raise ValueError( + f"base_url must be a full http(s) URL, got {base_url!r}" + ) + if not isinstance(panel_token, str) or not panel_token.strip(): + raise ValueError("panel_token must be a non-empty string") + if ( + not isinstance(timeout_seconds, (int, float)) + or isinstance(timeout_seconds, bool) + or timeout_seconds <= 0 + ): + raise ValueError( + f"timeout_seconds must be a positive number, got {timeout_seconds!r}" + ) + self._base_url = base_url.strip().rstrip("/") + self._panel_token = panel_token + self._session = session + self._timeout_seconds = float(timeout_seconds) + + async def get_snapshot(self) -> dict[str, Any]: + """Fetch and parse today's household snapshot. + + Returns the JSON the snapshot route returns (``today_iso``, + ``cycle_day``, and the per-child list — see + ``api/routes_panel.py`` for the documented shape). Raises + :class:`NestQuestApiError` on transport failure, timeout, or a + non-2xx response. + """ + return await self._request_json("GET", SNAPSHOT_PATH) + + async def complete_instance( + self, instance_id: int, actor_child_id: int + ) -> dict[str, Any]: + """Complete one quest instance as the tapped child profile. + + POSTs ``{"actor_child_id": actor_child_id}`` to the complete + route and returns the parsed response body (the route answers + ``{"status": ...}`` both for a first completion and for an + idempotent repeat, so a retried panel tap succeeds). Raises + :class:`NestQuestApiError` on transport failure, timeout, or a + non-2xx response — 401 for a wrong token, 404 for an unknown + instance. + """ + path = COMPLETE_INSTANCE_PATH.format(instance_id=instance_id) + return await self._request_json( + "POST", path, json_body={"actor_child_id": actor_child_id} + ) + + async def _request_json( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + ) -> Any: + """Run ONE request and return the parsed JSON body. + + The Bearer token rides the Authorization header only. A + non-2xx response raises :class:`NestQuestApiError` with its + status; anything the transport raises (connection, timeout) is + wrapped as a transport-level :class:`NestQuestApiError` with + the original error chained — never swallowed, never retried. + A 2xx body that is not valid JSON is a protocol failure and + raises typed too, carrying the status it arrived with. + """ + url = f"{self._base_url}{path}" + headers = {"Authorization": f"Bearer {self._panel_token}"} + try: + async with asyncio.timeout(self._timeout_seconds): + async with self._session.request( + method, url, headers=headers, json=json_body + ) as response: + status = response.status + if not 200 <= status < 300: + raise NestQuestApiError( + f"NestQuest API {method} {path} failed with " + f"HTTP {status}", + status=status, + ) + body_text = await response.text() + except asyncio.CancelledError: + # Cancellation is never converted: a shutting-down event + # loop must stop, not read a synthesized API error. + raise + except NestQuestApiError: + raise + except Exception as err: + raise NestQuestApiError( + f"NestQuest API {method} {path} transport failure: {err}", + status=None, + ) from err + try: + return json.loads(body_text) + except ValueError as err: + raise NestQuestApiError( + f"NestQuest API {method} {path} returned malformed " + f"JSON: {err}", + status=status, + ) from err \ No newline at end of file diff --git a/custom_components/nestquest/core/const.py b/custom_components/nestquest/core/const.py index fc72aff..dfecc08 100644 --- a/custom_components/nestquest/core/const.py +++ b/custom_components/nestquest/core/const.py @@ -101,6 +101,32 @@ CONF_END_OF_DAY_REPORT_ENABLED = "end_of_day_report_enabled" CONF_CELEBRATION_ENABLED = "celebration_enabled" DEFAULT_AUTOMATION_ENABLED = True +# Panel-plane API client (Feature 18): the integration reaches the +# NestQuest API service (Feature 16) over HTTP instead of the database. +# The base URL points at the API service host serving ``/api/v1/panel/*`` +# (uvicorn's default host/port is the sensible starting point); the panel +# service token is the static Bearer credential the panel plane requires +# (``NESTQUEST_PANEL_TOKEN`` on the API side). Both are stored on the +# entry like every other setting — options first, then the entry-data +# copy, then defaults. The TOKEN IS A SECRET: it is never logged, never +# echoed into error messages, and never committed. The shipped default +# token IS the empty string (DEFAULT_PANEL_TOKEN) — an entry with no +# configured token is therefore NOT a working client: the options flow +# accepts an empty token as "not configured yet" and only rejects +# malformed (padded/whitespace-only/non-string) values — the actual +# non-emptiness guard is enforced at client construction, where +# NestQuestApiClient fails fast on an empty/whitespace token +# (api_client.py). So a caller building the client from a bare +# entry must either ensure a token is configured first or handle the +# construction ValueError and surface an "API not configured" state — +# an unconfigured entry never yields a client that can only 401. +# This mirrors the API service's fail-closed stance (api/config.py: no +# default token exists). +CONF_API_BASE_URL = "api_base_url" +DEFAULT_API_BASE_URL = "http://127.0.0.1:8000" +CONF_PANEL_TOKEN = "panel_token" +DEFAULT_PANEL_TOKEN = "" + # Quest windows (D-008): a definition may declare several day windows; # each produces its own instance per day and groups the panel's Quest # Log into three columns. The clock ranges are stored here so they stay diff --git a/custom_components/nestquest/options_flow.py b/custom_components/nestquest/options_flow.py index 22e3ee2..26cdcb9 100644 --- a/custom_components/nestquest/options_flow.py +++ b/custom_components/nestquest/options_flow.py @@ -12,6 +12,7 @@ from .const import ( CONF_ADMIN_USER_IDS, CONF_AFTERNOON_REMINDER_ENABLED, CONF_AFTERNOON_REMINDER_TIME, + CONF_API_BASE_URL, CONF_CELEBRATION_ENABLED, CONF_DAY_ROLLOVER_TIME, CONF_END_OF_DAY_REPORT_ENABLED, @@ -21,7 +22,9 @@ from .const import ( CONF_MORNING_SUMMARY_TIME, CONF_NOTIFY_TARGET, CONF_PANEL_IDLE_TIMEOUT, + CONF_PANEL_TOKEN, CONF_UPDATE_INTERVAL, + DEFAULT_API_BASE_URL, DEFAULT_AFTERNOON_REMINDER_TIME, DEFAULT_AUTOMATION_ENABLED, DEFAULT_DAY_ROLLOVER_TIME, @@ -29,12 +32,14 @@ from .const import ( DEFAULT_HORIZON_DAYS, DEFAULT_MORNING_SUMMARY_TIME, DEFAULT_PANEL_IDLE_TIMEOUT, + DEFAULT_PANEL_TOKEN, DEFAULT_UPDATE_INTERVAL, DOMAIN, MIN_UPDATE_INTERVAL, TIME_PATTERN, ) from .admin_allowlist import list_admin_ids, set_admin_ids +from .api_client import is_valid_base_url MIN_HORIZON_DAYS = 1 MIN_PANEL_IDLE_TIMEOUT = 30 @@ -116,6 +121,25 @@ def _async_validate(user_input: dict[str, Any]) -> dict[str, str]: if toggle_value is not None and not isinstance(toggle_value, bool): errors[toggle_key] = "invalid" + # The panel-plane API connection (Feature 18) is OPTIONAL in raw + # submits the same way: a missing key keeps the current value. The + # base URL must be a full http(s) URL with a host — the same check + # the client's constructor enforces, so a malformed URL fails at + # the form, not at the first request. The token may be EMPTY (the + # API connection is simply not configured yet) but a non-empty one + # must be pasted unpadded: a padded or whitespace-only value is a + # clipboard mistake that would fail the token check at request + # time. + api_base_url = user_input.get(CONF_API_BASE_URL) + if api_base_url is not None and not is_valid_base_url(api_base_url): + errors[CONF_API_BASE_URL] = "invalid_url" + panel_token = user_input.get(CONF_PANEL_TOKEN) + if panel_token is not None and ( + not isinstance(panel_token, str) + or panel_token != panel_token.strip() + ): + errors[CONF_PANEL_TOKEN] = "invalid" + return errors @@ -186,6 +210,18 @@ def _build_schema( CONF_CELEBRATION_ENABLED, default=current[CONF_CELEBRATION_ENABLED], ): _strict_bool, + # Panel-plane API connection (Feature 18): the base URL the API + # client talks to and the panel service token it presents as + # the Bearer credential. Schema-validated submits always carry + # both (the defaults pre-fill from the stored values); raw + # callers omitting them keep their stored values (see + # _async_validate). + vol.Required( + CONF_API_BASE_URL, default=current[CONF_API_BASE_URL] + ): str, + vol.Required( + CONF_PANEL_TOKEN, default=current[CONF_PANEL_TOKEN] + ): str, } if admin_choices is not None: # HA's supported multi-select validator: the frontend can @@ -336,6 +372,11 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): data[notification_key] = user_input.get( notification_key, current[notification_key] ) + # The API connection section (Feature 18) stores the base URL + # and panel token the same fall-back-to-current way, so raw + # submits that predate the section keep their stored values. + for api_key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN): + data[api_key] = user_input.get(api_key, current[api_key]) if admin_ids_present: data[CONF_ADMIN_USER_IDS] = admin_ids elif admin_default: @@ -365,6 +406,8 @@ class NestQuestOptionsFlow(config_entries.OptionsFlowWithConfigEntry): (CONF_AFTERNOON_REMINDER_ENABLED, DEFAULT_AUTOMATION_ENABLED), (CONF_END_OF_DAY_REPORT_ENABLED, DEFAULT_AUTOMATION_ENABLED), (CONF_CELEBRATION_ENABLED, DEFAULT_AUTOMATION_ENABLED), + (CONF_API_BASE_URL, DEFAULT_API_BASE_URL), + (CONF_PANEL_TOKEN, DEFAULT_PANEL_TOKEN), ): if key in options: values[key] = options[key] diff --git a/custom_components/nestquest/strings.json b/custom_components/nestquest/strings.json index a37a39a..3e6312a 100644 --- a/custom_components/nestquest/strings.json +++ b/custom_components/nestquest/strings.json @@ -18,11 +18,13 @@ "step": { "init": { "title": "NestQuest Settings", - "description": "Adjust how NestQuest plans, rolls over days, and idles its panel.", + "description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.", "data": { "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", + "api_base_url": "NestQuest API base URL (http://host:port)", + "panel_token": "Panel service token", "admin_user_ids": "Admin users (parents)" } } @@ -30,6 +32,7 @@ "errors": { "invalid": "Invalid value.", "invalid_time": "Time must use 24-hour HH:MM format.", + "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", "invalid_admin": "Invalid admin selection." } diff --git a/custom_components/nestquest/translations/en.json b/custom_components/nestquest/translations/en.json index a37a39a..3e6312a 100644 --- a/custom_components/nestquest/translations/en.json +++ b/custom_components/nestquest/translations/en.json @@ -18,11 +18,13 @@ "step": { "init": { "title": "NestQuest Settings", - "description": "Adjust how NestQuest plans, rolls over days, and idles its panel.", + "description": "Adjust how NestQuest plans, rolls over days, idles its panel, and reaches the NestQuest API service.", "data": { "horizon_days": "Generation horizon (days)", "day_rollover_time": "Day rollover time (HH:MM)", "panel_idle_timeout": "Panel idle timeout (seconds)", + "api_base_url": "NestQuest API base URL (http://host:port)", + "panel_token": "Panel service token", "admin_user_ids": "Admin users (parents)" } } @@ -30,6 +32,7 @@ "errors": { "invalid": "Invalid value.", "invalid_time": "Time must use 24-hour HH:MM format.", + "invalid_url": "Enter the API base URL as a full http:// or https:// URL with a host.", "no_admins": "Select at least one admin user; an empty allowlist would lock everyone out.", "invalid_admin": "Invalid admin selection." } diff --git a/tests/test_api_client.py b/tests/test_api_client.py new file mode 100644 index 0000000..4e9d5a9 --- /dev/null +++ b/tests/test_api_client.py @@ -0,0 +1,450 @@ +"""Tests for the panel-plane API client (Feature 18). + +The client is exercised against a STUB transport only — no network, no +aiohttp, no homeassistant: the injected session is a fake that speaks +the same ``request(...)`` surface aiohttp's ``ClientSession`` exposes +and records every call, so the tests assert exactly what the client +puts on the wire (method, URL, Bearer header, JSON body) and how every +failure mode (non-2xx, connection failure, timeout, malformed JSON) +surfaces as the typed :class:`NestQuestApiError`. +""" +from __future__ import annotations + +import asyncio +import json +import sys +import types + +import pytest + +from conftest import make_config_entry + +from custom_components.nestquest import api_client +from custom_components.nestquest.api_client import ( + NestQuestApiClient, + NestQuestApiError, + is_valid_base_url, + resolve_api_config, +) +from custom_components.nestquest.const import ( + CONF_API_BASE_URL, + CONF_PANEL_TOKEN, + DEFAULT_API_BASE_URL, + DEFAULT_PANEL_TOKEN, +) + +BASE_URL = "http://api.test:8000" +TOKEN = "test-panel-token" + +#: A realistic panel snapshot payload (the documented shape of the +#: Feature 16 snapshot route). +SNAPSHOT_PAYLOAD = { + "today_iso": "2026-09-23", + "cycle_day": 2, + "children": [ + { + "child_id": 1, + "child_name": "Ada", + "present": False, + "next_present": "2026-09-24", + "due_today": 1, + "completed_today": 0, + "remaining_today": 1, + "completion_pct": 0, + "instances": [ + { + "id": 7, + "definition_id": 3, + "child_id": 1, + "title": "Pack bag", + "icon": None, + "window": "morning", + "due_time": "10:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + } + ], + } + ], +} + + +class StubResponse: + """A fake aiohttp response: ``status`` plus ``await text()``.""" + + def __init__(self, status: int, body: str) -> None: + self.status = status + self._body = body + + async def text(self) -> str: + return self._body + + +class _OutcomeContext: + """Async context manager yielding one scripted outcome. + + Mirrors aiohttp's ``session.request(...)`` return value: entering + the context performs the request, so a scripted transport error + raises THERE — before any response object exists, exactly where + aiohttp's connector raises on a refused connection. + """ + + def __init__(self, response, error) -> None: + self._response = response + self._error = error + + async def __aenter__(self): + if self._error is not None: + raise self._error + return self._response + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + +class StubTransport: + """A no-network stand-in for aiohttp's ``ClientSession``. + + Records every ``request(...)`` call as a dict (method, url, headers, + json body) and answers from a scripted queue of outcomes — a + :class:`StubResponse` or an exception per call, consumed in order — + so each test drives exactly one exchange. + """ + + def __init__(self, *outcomes) -> None: + self.calls: list[dict] = [] + self._outcomes = list(outcomes) + + def request(self, method: str, url: str, **kwargs): + self.calls.append( + { + "method": method, + "url": url, + "headers": kwargs.get("headers"), + "json": kwargs.get("json"), + } + ) + if not self._outcomes: + raise AssertionError("StubTransport ran out of scripted outcomes") + outcome = self._outcomes.pop(0) + if isinstance(outcome, BaseException): + return _OutcomeContext(None, outcome) + return _OutcomeContext(outcome, None) + + +class HangingTransport: + """A transport whose request never answers (for the timeout test).""" + + def __init__(self) -> None: + self.calls: list[dict] = [] + + def request(self, method: str, url: str, **kwargs): + self.calls.append({"method": method, "url": url}) + return _HangingContext() + + +class _HangingContext: + async def __aenter__(self): + await asyncio.sleep(5) + raise AssertionError("hanging transport was never cancelled") + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + +def make_client(transport, *, timeout_seconds: float = 5.0) -> NestQuestApiClient: + """Build a client over the stub transport with the test credentials.""" + return NestQuestApiClient( + BASE_URL, TOKEN, transport, timeout_seconds=timeout_seconds + ) + + +# --------------------------------------------------------------------------- +# Success paths +# --------------------------------------------------------------------------- + + +async def test_get_snapshot_parses_payload_and_sends_bearer() -> None: + transport = StubTransport( + StubResponse(200, json.dumps(SNAPSHOT_PAYLOAD)) + ) + client = make_client(transport) + + snapshot = await client.get_snapshot() + + assert snapshot == SNAPSHOT_PAYLOAD + call = transport.calls[0] + assert call["method"] == "GET" + assert call["url"] == f"{BASE_URL}/api/v1/panel/snapshot" + assert call["headers"] == {"Authorization": f"Bearer {TOKEN}"} + assert call["json"] is None + + +async def test_complete_instance_posts_body_and_headers() -> None: + transport = StubTransport(StubResponse(200, '{"status": "done"}')) + client = make_client(transport) + + result = await client.complete_instance(42, 3) + + assert result == {"status": "done"} + call = transport.calls[0] + assert call["method"] == "POST" + assert call["url"] == f"{BASE_URL}/api/v1/panel/instances/42/complete" + assert call["json"] == {"actor_child_id": 3} + assert call["headers"]["Authorization"] == f"Bearer {TOKEN}" + + +async def test_base_url_trailing_slash_is_normalized() -> None: + """A base URL with a trailing slash must not double the path slash.""" + transport = StubTransport(StubResponse(200, "{}")) + client = NestQuestApiClient( + f"{BASE_URL}/", TOKEN, transport, timeout_seconds=5.0 + ) + await client.get_snapshot() + assert transport.calls[0]["url"] == f"{BASE_URL}/api/v1/panel/snapshot" + + +# --------------------------------------------------------------------------- +# HTTP failures: the typed error carries the status +# --------------------------------------------------------------------------- + + +async def test_snapshot_401_raises_typed_error() -> None: + """A wrong/missing token answers 401 and surfaces with that status.""" + transport = StubTransport( + StubResponse(401, '{"detail": "Missing or invalid panel service token"}') + ) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + error = excinfo.value + assert error.status == 401 + assert not error.is_transport_error + assert error.__cause__ is None + # The token is a secret: it never reaches the raised message. + assert TOKEN not in str(error) + + +async def test_complete_unknown_instance_404_raises_typed_error() -> None: + transport = StubTransport( + StubResponse(404, '{"detail": "Quest instance not found"}') + ) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.complete_instance(999999, 1) + + assert excinfo.value.status == 404 + assert not excinfo.value.is_transport_error + + +async def test_snapshot_500_raises_typed_error() -> None: + """Any non-2xx response is typed, whatever the status.""" + transport = StubTransport(StubResponse(500, "{}")) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + assert excinfo.value.status == 500 + + +async def test_malformed_2xx_body_raises_typed_with_status() -> None: + """A 2xx body that is not JSON is a protocol failure, carrying its status.""" + transport = StubTransport(StubResponse(200, "not json")) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + assert excinfo.value.status == 200 + + +# --------------------------------------------------------------------------- +# Transport failures: the typed error carries no status and chains the cause +# --------------------------------------------------------------------------- + + +async def test_connection_failure_raises_transport_error() -> None: + transport = StubTransport(OSError("connection refused")) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + error = excinfo.value + assert error.status is None + assert error.is_transport_error + assert isinstance(error.__cause__, OSError) + + +async def test_connection_failure_on_complete_is_typed() -> None: + transport = StubTransport(OSError("connection refused")) + client = make_client(transport) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.complete_instance(7, 1) + + assert excinfo.value.status is None + assert excinfo.value.is_transport_error + + +async def test_request_timeout_raises_transport_error() -> None: + """A hung API call surfaces as a typed transport error, once, with + no retry: the one request is cancelled by the client's timeout.""" + transport = HangingTransport() + client = make_client(transport, timeout_seconds=0.05) + + with pytest.raises(NestQuestApiError) as excinfo: + await client.get_snapshot() + + assert excinfo.value.status is None + assert excinfo.value.is_transport_error + assert len(transport.calls) == 1 + + +# --------------------------------------------------------------------------- +# Construction and config resolution +# --------------------------------------------------------------------------- + + +def test_constructor_requires_full_base_url() -> None: + for bad_url in (None, "", " ", "api.lan:8000", "http://", "ftp://x"): + with pytest.raises(ValueError): + NestQuestApiClient(bad_url, TOKEN, StubTransport()) + + +def test_constructor_requires_non_empty_token() -> None: + for bad_token in (None, "", " "): + with pytest.raises(ValueError): + NestQuestApiClient(BASE_URL, bad_token, StubTransport()) + + +def test_constructor_requires_positive_timeout() -> None: + for bad_timeout in (0, -1, "5", True, None): + with pytest.raises(ValueError): + NestQuestApiClient( + BASE_URL, TOKEN, StubTransport(), timeout_seconds=bad_timeout + ) + + +def test_is_valid_base_url_accepts_full_http_urls() -> None: + assert is_valid_base_url("http://127.0.0.1:8000") + assert is_valid_base_url("https://nestquest.lan") + assert is_valid_base_url(" http://127.0.0.1:8000 ") + assert not is_valid_base_url("api.lan:8000") + assert not is_valid_base_url("http://") + assert not is_valid_base_url("") + assert not is_valid_base_url(None) + assert not is_valid_base_url(42) + + +def test_resolve_api_config_prefers_options_then_data_then_defaults() -> None: + options_only = make_config_entry( + options={CONF_API_BASE_URL: "http://from-options:1"} + ) + assert resolve_api_config(options_only) == ( + "http://from-options:1", + DEFAULT_PANEL_TOKEN, + ) + + data_only = make_config_entry( + data={ + CONF_API_BASE_URL: "http://from-data:2", + CONF_PANEL_TOKEN: "from-data", + } + ) + assert resolve_api_config(data_only) == ("http://from-data:2", "from-data") + + both = make_config_entry( + options={CONF_API_BASE_URL: "http://from-options:3"}, + data={ + CONF_API_BASE_URL: "http://from-data:4", + CONF_PANEL_TOKEN: "from-data", + }, + ) + assert resolve_api_config(both) == ( + "http://from-options:3", + "from-data", + ) + + bare = make_config_entry() + assert resolve_api_config(bare) == (DEFAULT_API_BASE_URL, DEFAULT_PANEL_TOKEN) + + +def test_client_from_entry_reads_config_and_shared_session(monkeypatch) -> None: + """The production factory resolves the entry config and hands in HA's + shared aiohttp session (stubbed module — no real HA, no network).""" + fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client") + shared_session = object() + fake_module.async_get_clientsession = lambda hass: shared_session + monkeypatch.setitem( + sys.modules, "homeassistant.helpers.aiohttp_client", fake_module + ) + entry = make_config_entry( + options={ + CONF_API_BASE_URL: "http://panel.lan:8443", + CONF_PANEL_TOKEN: "entry-token", + } + ) + + client = api_client.client_from_entry(object(), entry) + + assert client._session is shared_session + assert client._base_url == "http://panel.lan:8443" + assert client._panel_token == "entry-token" + + +def test_client_from_entry_unconfigured_token_raises_at_construction( + monkeypatch, +) -> None: + """An entry with NO configured token (the shipped default) raises + ValueError AT CONSTRUCTION — the client's fail-fast guard — rather + than silently building a client that can only 401 (the documented + contract: the options flow's non-empty validation is the user-facing + guard; the coordinator must ensure a token is configured first or + handle the construction error as "API not configured").""" + fake_module = types.ModuleType("homeassistant.helpers.aiohttp_client") + fake_module.async_get_clientsession = lambda hass: object() + monkeypatch.setitem( + sys.modules, "homeassistant.helpers.aiohttp_client", fake_module + ) + entry = make_config_entry( + options={CONF_API_BASE_URL: DEFAULT_API_BASE_URL}, + ) + assert resolve_api_config(entry)[1] == DEFAULT_PANEL_TOKEN == "" + + with pytest.raises(ValueError, match="panel_token must be a non-empty"): + api_client.client_from_entry(object(), entry) + + # Same for a whitespace-only stored token: whitespace is not a token. + entry_ws = make_config_entry(options={CONF_PANEL_TOKEN: " "}) + with pytest.raises(ValueError, match="panel_token must be a non-empty"): + api_client.client_from_entry(object(), entry_ws) + + +async def test_client_logs_nothing() -> None: + """The client emits no log records at all (the token must never + reach a log line), success or failure.""" + import logging + + records: list[logging.LogRecord] = [] + + class _Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = _Capture() + logging.getLogger().addHandler(handler) + try: + transport = StubTransport(StubResponse(401, "{}")) + client = make_client(transport) + with pytest.raises(NestQuestApiError): + await client.get_snapshot() + finally: + logging.getLogger().removeHandler(handler) + assert records == [] \ No newline at end of file diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 1c8bf88..c2bcdce 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -15,22 +15,26 @@ from custom_components.nestquest import config_flow from custom_components.nestquest import options_flow from custom_components.nestquest.const import ( CONF_ADMIN_USER_IDS, - CONF_DAY_ROLLOVER_TIME, - CONF_HORIZON_DAYS, - CONF_PANEL_IDLE_TIMEOUT, - CONF_UPDATE_INTERVAL, CONF_AFTERNOON_REMINDER_ENABLED, CONF_AFTERNOON_REMINDER_TIME, + CONF_API_BASE_URL, CONF_CELEBRATION_ENABLED, + CONF_DAY_ROLLOVER_TIME, CONF_END_OF_DAY_REPORT_ENABLED, CONF_END_OF_DAY_REPORT_TIME, + CONF_HORIZON_DAYS, CONF_MORNING_SUMMARY_ENABLED, CONF_MORNING_SUMMARY_TIME, CONF_NOTIFY_TARGET, + CONF_PANEL_IDLE_TIMEOUT, + CONF_PANEL_TOKEN, + CONF_UPDATE_INTERVAL, + DEFAULT_API_BASE_URL, DEFAULT_AFTERNOON_REMINDER_TIME, DEFAULT_AUTOMATION_ENABLED, DEFAULT_END_OF_DAY_REPORT_TIME, DEFAULT_MORNING_SUMMARY_TIME, + DEFAULT_PANEL_TOKEN, DEFAULT_UPDATE_INTERVAL, DEFAULT_DAY_ROLLOVER_TIME, DEFAULT_HORIZON_DAYS, @@ -49,8 +53,9 @@ VALID_INPUT = { } #: What a schema-validated submit produces from a bare VALID_INPUT on a -#: fresh entry: the Feature 10 refresh interval and the Feature 11 -#: notification section fill from their defaults. +#: fresh entry: the Feature 10 refresh interval, the Feature 11 +#: notification section, and the Feature 18 API connection fill from +#: their defaults. FULL_INPUT = { **VALID_INPUT, CONF_UPDATE_INTERVAL: DEFAULT_UPDATE_INTERVAL, @@ -62,6 +67,8 @@ FULL_INPUT = { CONF_AFTERNOON_REMINDER_ENABLED: DEFAULT_AUTOMATION_ENABLED, CONF_END_OF_DAY_REPORT_ENABLED: DEFAULT_AUTOMATION_ENABLED, CONF_CELEBRATION_ENABLED: DEFAULT_AUTOMATION_ENABLED, + CONF_API_BASE_URL: DEFAULT_API_BASE_URL, + CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN, } @@ -259,6 +266,8 @@ def test_options_flow_schema_applies_stored_defaults() -> None: CONF_AFTERNOON_REMINDER_ENABLED: True, CONF_END_OF_DAY_REPORT_ENABLED: True, CONF_CELEBRATION_ENABLED: True, + CONF_API_BASE_URL: DEFAULT_API_BASE_URL, + CONF_PANEL_TOKEN: DEFAULT_PANEL_TOKEN, CONF_ADMIN_USER_IDS: [], } @@ -272,6 +281,9 @@ def test_options_flow_schema_coerces_field_types() -> None: assert validators[CONF_HORIZON_DAYS] is int assert validators[CONF_DAY_ROLLOVER_TIME] is str assert validators[CONF_PANEL_IDLE_TIMEOUT] is int + # The Feature 18 API connection fields are free-text strings. + assert validators[CONF_API_BASE_URL] is str + assert validators[CONF_PANEL_TOKEN] is str @pytest.mark.parametrize("bad_horizon", [0, -5]) @@ -326,8 +338,10 @@ def test_options_flow_rejects_invalid_idle_timeout(bad_timeout) -> None: assert result["errors"] == {CONF_PANEL_IDLE_TIMEOUT: "invalid"} -def test_options_flow_creates_entry_with_four_keys() -> None: - """A valid submit creates an entry with exactly the four validated keys.""" +def test_options_flow_creates_entry_with_validated_keys() -> None: + """A valid submit creates an entry with exactly the validated keys + (the legacy settings plus every later section's keys, filled from + defaults where the raw submit omitted them).""" flow = _make_flow(_make_entry()) result = _run(flow.async_step_init(dict(VALID_INPUT))) assert result["type"] == "create_entry" @@ -799,3 +813,91 @@ def test_options_flow_no_hard_coded_personal_target_in_source() -> None: assert const.__dict__.get("DEFAULT_NOTIFY_TARGET", "") == "" form = _run(_make_flow(_make_entry()).async_step_init(None)) assert form["data_schema"]({})[CONF_NOTIFY_TARGET] == "" + + +# --------------------------------------------------------------------------- +# Panel-plane API connection section (Feature 18) +# --------------------------------------------------------------------------- + + +def test_options_flow_api_section_defaults_saved() -> None: + """A bare submit saves the API connection defaults: the const base + URL and an EMPTY panel token (no token is ever invented).""" + flow = _make_flow(_make_entry()) + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_API_BASE_URL] == DEFAULT_API_BASE_URL + assert result["data"][CONF_PANEL_TOKEN] == "" + + +def test_options_flow_api_base_url_must_be_full_http_url() -> None: + """Scheme-less, host-less, empty, and non-string base URLs reject + with a field error — the same rule the client's constructor + enforces, so a malformed URL fails at the form, not at the first + request.""" + flow = _make_flow(_make_entry()) + for bad_url in ("api.lan:8000", "http://", "", " ", 42): + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_API_BASE_URL: bad_url} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_API_BASE_URL: "invalid_url"} + + +def test_options_flow_panel_token_validation() -> None: + """The EMPTY token is valid (the API connection is simply not + configured yet); padded and non-string values reject — a padded + paste would fail the token check at request time instead.""" + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init({**VALID_INPUT, CONF_PANEL_TOKEN: ""}) + ) + assert result["type"] == "create_entry" + assert result["data"][CONF_PANEL_TOKEN] == "" + + for bad_token in (" secret ", " ", 42): + flow = _make_flow(_make_entry()) + result = _run( + flow.async_step_init( + {**VALID_INPUT, CONF_PANEL_TOKEN: bad_token} + ) + ) + assert result["type"] == "form" + assert result["errors"] == {CONF_PANEL_TOKEN: "invalid"} + + +def test_options_flow_api_section_stored_values_round_trip() -> None: + """Stored API settings are the form's defaults and a raw legacy + submit without them keeps the stored values.""" + entry = _make_entry( + options={ + **VALID_INPUT, + CONF_API_BASE_URL: "http://panel.lan:8443", + CONF_PANEL_TOKEN: "stored-token", + } + ) + flow = _make_flow(entry) + form = _run(flow.async_step_init(None)) + schema_defaults = form["data_schema"]({}) + assert schema_defaults[CONF_API_BASE_URL] == "http://panel.lan:8443" + assert schema_defaults[CONF_PANEL_TOKEN] == "stored-token" + + # A raw submit carrying only the legacy keys keeps the stored + # API connection settings. + result = _run(flow.async_step_init(dict(VALID_INPUT))) + assert result["type"] == "create_entry" + assert result["data"][CONF_API_BASE_URL] == "http://panel.lan:8443" + assert result["data"][CONF_PANEL_TOKEN] == "stored-token" + + +def test_options_flow_strings_cover_api_section( + strings: dict, translations_en: dict +) -> None: + """Both string files name the API fields and the URL error.""" + data = strings["options"]["step"]["init"]["data"] + for key in (CONF_API_BASE_URL, CONF_PANEL_TOKEN): + assert data.get(key), f"Missing field name for '{key}'" + assert strings["options"]["errors"]["invalid_url"] + assert translations_en["options"] == strings["options"]