Task 37d5e806: API client module for the integration (Feature 18) (#166)
This commit was merged in pull request #166.
This commit is contained in:
@@ -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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user