Task 37d5e806: API client module for the integration (Feature 18) (#166)

This commit was merged in pull request #166.
This commit is contained in:
2026-09-23 05:12:27 -04:00
parent d78ef196d0
commit 78fd75ec45
7 changed files with 920 additions and 10 deletions
+283
View File
@@ -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
+26
View File
@@ -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
@@ -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]
+4 -1
View File
@@ -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."
}
@@ -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."
}
+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 == []
+110 -8
View File
@@ -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"]