Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
216 lines
7.3 KiB
Python
216 lines
7.3 KiB
Python
"""The ``nestquest.complete_quest`` proxy to the API client (Feature 18).
|
|
|
|
The service is a thin proxy: a panel tap must reach the API's panel
|
|
complete route through the entry's ONE shared API client (the same
|
|
instance the coordinator polls and the SSE subscription reads), with
|
|
exactly the payload contract the panel cards send (``instance_id``,
|
|
``actor=panel``, ``actor_child_id``). The service performs NO local
|
|
completion and fires NO local bus event — the API service emits the
|
|
transition on its SSE stream and the integration's subscription
|
|
re-fires it (firing it locally too would double-fire) — and it
|
|
refreshes the coordinator after a successful call so the sensors
|
|
reflect the completion immediately. Every failure (a typed
|
|
:class:`NestQuestApiError`, an unconfigured client, a non-panel actor)
|
|
surfaces as a :class:`HomeAssistantError`, token-free.
|
|
|
|
The scripted clients here stand in for the API service: recording and
|
|
failing ``complete_instance`` implementations, no network, no aiohttp.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
from conftest import ScriptedPanelClient, set_coordinator_client, wire_entry_to_registry
|
|
|
|
from custom_components.nestquest import async_setup_entry
|
|
from custom_components.nestquest.api_client import NestQuestApiError
|
|
from custom_components.nestquest.const import (
|
|
CONF_PANEL_TOKEN,
|
|
DOMAIN,
|
|
EVENT_CHILD_DAY_COMPLETE,
|
|
EVENT_QUEST_COMPLETED,
|
|
SERVICE_COMPLETE_QUEST,
|
|
)
|
|
|
|
|
|
class _RecordingApiClient(ScriptedPanelClient):
|
|
"""Snapshot-only stand-in that RECORDS completion calls.
|
|
|
|
``complete_instance`` deliberately performs NO local write: the
|
|
API service is remote, and the assertions below must be able to
|
|
prove the service handler itself completed nothing locally.
|
|
"""
|
|
|
|
|
|
class _FailingApiClient(_RecordingApiClient):
|
|
"""A stand-in whose completion fails the way the client types it."""
|
|
|
|
def __init__(self, error: NestQuestApiError, *outcomes) -> None:
|
|
super().__init__(*outcomes)
|
|
self.fail_complete_with = error
|
|
|
|
|
|
def _empty_snapshot() -> dict:
|
|
return {
|
|
"today_iso": "2026-09-23",
|
|
"cycle_day": 0,
|
|
"timezone": "",
|
|
"children": [],
|
|
}
|
|
|
|
|
|
async def _setup_entry(hass, make_entry, client):
|
|
"""Wire, script, and set up an entry against the scripted client."""
|
|
entry = wire_entry_to_registry(
|
|
make_entry(data={CONF_PANEL_TOKEN: "secret-panel-token"}),
|
|
hass.registry,
|
|
)
|
|
set_coordinator_client(entry, client)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
return entry
|
|
|
|
|
|
def _record_refreshes(entry) -> list[bool]:
|
|
"""Replace the coordinator's refresh with a recording stand-in."""
|
|
refreshes: list[bool] = []
|
|
|
|
async def _fake_refresh():
|
|
refreshes.append(True)
|
|
|
|
entry.runtime_data.coordinator.async_refresh = _fake_refresh
|
|
return refreshes
|
|
|
|
|
|
async def test_complete_quest_reaches_the_shared_api_client(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A panel tap calls ``complete_instance`` on the entry's shared
|
|
client with the tapped child, fires NO local event, and refreshes
|
|
the coordinator immediately."""
|
|
client = _RecordingApiClient(_empty_snapshot())
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
assert entry.runtime_data.coordinator.api_client is client, (
|
|
"the service must proxy through the entry's ONE shared client"
|
|
)
|
|
refreshes = _record_refreshes(entry)
|
|
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": 7,
|
|
"actor": "panel",
|
|
"actor_child_id": 1,
|
|
},
|
|
)
|
|
|
|
assert client.complete_calls == [(7, 1)]
|
|
# No local firing: the completed (and day-complete) events arrive
|
|
# over the SSE stream, re-fired by the subscription — never from
|
|
# this handler.
|
|
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
|
# The old immediate-update behaviour: the sensors' coordinator
|
|
# refresh runs before the service call returns.
|
|
assert refreshes == [True]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "fragment"),
|
|
[
|
|
(
|
|
NestQuestApiError(
|
|
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
|
"failed with HTTP 401",
|
|
status=401,
|
|
),
|
|
"failed with HTTP 401",
|
|
),
|
|
(
|
|
NestQuestApiError(
|
|
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
|
"transport failure: connection refused",
|
|
status=None,
|
|
),
|
|
"transport failure",
|
|
),
|
|
],
|
|
)
|
|
async def test_api_failure_surfaces_as_a_service_error(
|
|
hass, make_entry, error, fragment
|
|
) -> None:
|
|
"""A typed API failure (non-2xx or transport) raises
|
|
``HomeAssistantError`` carrying the typed message — which never
|
|
includes the panel token — with nothing fired locally, and no
|
|
refresh for a mutation that never happened."""
|
|
client = _FailingApiClient(error, _empty_snapshot())
|
|
entry = await _setup_entry(hass, make_entry, client)
|
|
refreshes = _record_refreshes(entry)
|
|
|
|
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": 7,
|
|
"actor": "panel",
|
|
"actor_child_id": 1,
|
|
},
|
|
)
|
|
# The typed error is chained, and the service error carries the
|
|
# typed message only — never the panel token.
|
|
assert excinfo.value.__cause__ is error
|
|
assert "secret-panel-token" not in str(excinfo.value)
|
|
assert client.complete_calls == [(7, 1)]
|
|
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
|
assert refreshes == []
|
|
|
|
|
|
async def test_unconfigured_api_client_surfaces_as_a_service_error(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""An entry with no completion surface (the unconfigured-token
|
|
stub the coordinator swaps in) fails the service clearly instead
|
|
of reaching an attribute error."""
|
|
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
|
|
|
await _setup_entry(hass, make_entry, _UnconfiguredApiClient())
|
|
|
|
with pytest.raises(
|
|
HomeAssistantError, match="NestQuest API is not configured"
|
|
):
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": 7,
|
|
"actor": "panel",
|
|
"actor_child_id": 1,
|
|
},
|
|
)
|
|
|
|
|
|
async def test_non_panel_actor_is_rejected_before_the_client(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""The API's panel route completes as the tapped child only: a
|
|
user-actor call fails fast with a clear error and never reaches
|
|
the client."""
|
|
client = _RecordingApiClient(_empty_snapshot())
|
|
await _setup_entry(hass, make_entry, client)
|
|
|
|
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": 7,
|
|
"actor": "user",
|
|
"actor_user_id": "admin-1",
|
|
},
|
|
context={"user_id": "admin-1"},
|
|
)
|
|
assert client.complete_calls == []
|