WHY: Feature 18 makes the integration a client of the NestQuest API service — every database write must go through the API. The complete_quest service still wrote the local database and fired the completed event itself, which would double-fire once the SSE subscription re-fires the API's transition (and stops being true the moment the local database is dropped). The service now proxies to the entry's ONE shared API client (complete_instance: the panel complete route) and keeps only the old immediate-update behaviour: a successful call refreshes the coordinator so the sensors reflect the completion at once; the bus event comes solely from the SSE stream. Typed API failures (and an unconfigured client, and a non-panel actor) surface as HomeAssistantError with token-free messages instead of being swallowed; the payload contract (instance_id, actor=panel, actor_child_id) is unchanged for the panel cards. Tests: new tests/test_complete_quest_proxy.py asserts the call reaches the shared client with the right arguments, that no local write or local event happens on the API path, that a non-2xx/transport failure raises a service error chained to the typed error (never containing the token), and that the unconfigured stub fails clearly. Existing tests that drove complete_quest through the local database move to the API path via the harness stand-in client (which performs the same core completion and publishes the same SSE frames the API route does) and assert their bus events after the frames are delivered through the real SSE subscription; the shim-level tests drive the events shim directly, the way its remaining callers invoke it. Task 47c7c90e (complete_quest proxies to the API, Feature 18).
284 lines
9.7 KiB
Python
284 lines
9.7 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
|
|
database completion and fires NO local bus event — the API service
|
|
emits the transition on its SSE stream and the integration's
|
|
subscription re-fires it (firing it locally too would double-fire) —
|
|
and it refreshes the coordinator after a successful call so the
|
|
sensors reflect the completion immediately. Every failure (a typed
|
|
:class:`NestQuestApiError`, an unconfigured client, a non-panel
|
|
actor) surfaces as a :class:`HomeAssistantError`, token-free.
|
|
|
|
The scripted clients here stand in for the API service: recording and
|
|
failing ``complete_instance`` implementations, no network, no aiohttp.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
import pytest
|
|
from homeassistant.exceptions import HomeAssistantError
|
|
|
|
from conftest import (
|
|
LazyLocalSnapshotClient,
|
|
set_coordinator_client,
|
|
wire_entry_to_registry,
|
|
)
|
|
|
|
from custom_components.nestquest import async_setup_entry
|
|
from custom_components.nestquest.api_client import NestQuestApiError
|
|
from custom_components.nestquest.const import (
|
|
CONF_ADMIN_USER_IDS,
|
|
CONF_PANEL_TOKEN,
|
|
DOMAIN,
|
|
EVENT_CHILD_DAY_COMPLETE,
|
|
EVENT_QUEST_COMPLETED,
|
|
SERVICE_COMPLETE_QUEST,
|
|
)
|
|
from custom_components.nestquest.dao_instances import QuestInstancesDao
|
|
from custom_components.nestquest.materialize import materialize
|
|
from custom_components.nestquest.quest_definitions import (
|
|
create_quest_definition,
|
|
)
|
|
from custom_components.nestquest.recurrence import ScheduleRule
|
|
|
|
ADMIN_CTX = {"user_id": "admin-1"}
|
|
|
|
|
|
class _RecordingApiClient(LazyLocalSnapshotClient):
|
|
"""Snapshot-only stand-in that RECORDS completion calls.
|
|
|
|
``complete_instance`` deliberately performs NO database write: the
|
|
API service is remote, and the assertions below must be able to
|
|
prove the service handler itself completed nothing locally.
|
|
"""
|
|
|
|
def __init__(self, hass, entry_id: str) -> None:
|
|
super().__init__(hass, entry_id)
|
|
self.complete_calls: list[tuple[int, int]] = []
|
|
|
|
async def complete_instance(
|
|
self, instance_id: int, actor_child_id: int
|
|
) -> dict:
|
|
self.complete_calls.append((instance_id, actor_child_id))
|
|
return {"status": "done"}
|
|
|
|
|
|
class _FailingApiClient(_RecordingApiClient):
|
|
"""A stand-in whose completion fails the way the client types it."""
|
|
|
|
def __init__(self, hass, entry_id: str, error: NestQuestApiError) -> None:
|
|
super().__init__(hass, entry_id)
|
|
self._error = error
|
|
|
|
async def complete_instance(
|
|
self, instance_id: int, actor_child_id: int
|
|
) -> dict:
|
|
self.complete_calls.append((instance_id, actor_child_id))
|
|
raise self._error
|
|
|
|
|
|
async def _setup_seeded_entry(hass, make_entry, client):
|
|
"""Wire, script, and set up an entry with one child's daily quest."""
|
|
entry = wire_entry_to_registry(
|
|
make_entry(
|
|
data={
|
|
CONF_ADMIN_USER_IDS: [ADMIN_CTX["user_id"]],
|
|
CONF_PANEL_TOKEN: "secret-panel-token",
|
|
}
|
|
),
|
|
hass.registry,
|
|
)
|
|
set_coordinator_client(entry, client)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
database = entry.runtime_data.database
|
|
from custom_components.nestquest.children import create_child
|
|
|
|
child = await create_child(database, "Ada")
|
|
today = datetime.date.today()
|
|
await create_quest_definition(
|
|
database,
|
|
"Brush teeth",
|
|
ScheduleRule.from_dict(
|
|
{"rule_type": "daily", "start_date": today.isoformat()}
|
|
),
|
|
[child.id],
|
|
["morning"],
|
|
)
|
|
end = (today + datetime.timedelta(days=3)).isoformat()
|
|
await materialize(database, today.isoformat(), end, today=today)
|
|
instances = await QuestInstancesDao(database).list_by_date_range(
|
|
child.id, today.isoformat(), today.isoformat()
|
|
)
|
|
assert instances
|
|
return entry, client, child, instances[0]
|
|
|
|
|
|
def _record_refreshes(entry) -> list[bool]:
|
|
"""Replace the coordinator's refresh with a recording stand-in."""
|
|
refreshes: list[bool] = []
|
|
|
|
async def _fake_refresh():
|
|
refreshes.append(True)
|
|
|
|
entry.runtime_data.coordinator.async_refresh = _fake_refresh
|
|
return refreshes
|
|
|
|
|
|
async def test_complete_quest_reaches_the_shared_api_client(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A panel tap calls ``complete_instance`` on the entry's shared
|
|
client with the tapped child, completes NOTHING locally, fires NO
|
|
local event, and refreshes the coordinator immediately."""
|
|
entry, client, child, instance = await _setup_seeded_entry(
|
|
hass, make_entry, _RecordingApiClient(hass, "test_entry")
|
|
)
|
|
assert entry.runtime_data.coordinator.api_client is client, (
|
|
"the service must proxy through the entry's ONE shared client"
|
|
)
|
|
refreshes = _record_refreshes(entry)
|
|
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": instance.id,
|
|
"actor": "panel",
|
|
"actor_child_id": child.id,
|
|
},
|
|
)
|
|
|
|
assert client.complete_calls == [(instance.id, child.id)]
|
|
# No local completion: the instance is still open in the local
|
|
# database — only the API service writes (through its own client).
|
|
from custom_components.nestquest.completion import instance_state
|
|
|
|
assert (
|
|
await instance_state(entry.runtime_data.database, instance.id)
|
|
== "open"
|
|
)
|
|
# No local firing: the completed (and day-complete) events arrive
|
|
# over the SSE stream, re-fired by the subscription — never from
|
|
# this handler.
|
|
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
|
# The old immediate-update behaviour: the sensors' coordinator
|
|
# refresh runs before the service call returns.
|
|
assert refreshes == [True]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "fragment"),
|
|
[
|
|
(
|
|
NestQuestApiError(
|
|
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
|
"failed with HTTP 401",
|
|
status=401,
|
|
),
|
|
"failed with HTTP 401",
|
|
),
|
|
(
|
|
NestQuestApiError(
|
|
"NestQuest API POST /api/v1/panel/instances/7/complete "
|
|
"transport failure: connection refused",
|
|
status=None,
|
|
),
|
|
"transport failure",
|
|
),
|
|
],
|
|
)
|
|
async def test_api_failure_surfaces_as_a_service_error(
|
|
hass, make_entry, error, fragment
|
|
) -> None:
|
|
"""A typed API failure (non-2xx or transport) raises
|
|
``HomeAssistantError`` carrying the typed message — which never
|
|
includes the panel token — with nothing completed or fired
|
|
locally, and no refresh for a mutation that never happened."""
|
|
entry, client, child, instance = await _setup_seeded_entry(
|
|
hass,
|
|
make_entry,
|
|
_FailingApiClient(hass, "test_entry", error),
|
|
)
|
|
refreshes = _record_refreshes(entry)
|
|
|
|
with pytest.raises(HomeAssistantError, match=fragment) as excinfo:
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": instance.id,
|
|
"actor": "panel",
|
|
"actor_child_id": child.id,
|
|
},
|
|
)
|
|
# The typed error is chained, and the service error carries the
|
|
# typed message only — never the panel token.
|
|
assert excinfo.value.__cause__ is error
|
|
assert "secret-panel-token" not in str(excinfo.value)
|
|
assert client.complete_calls == [(instance.id, child.id)]
|
|
from custom_components.nestquest.completion import instance_state
|
|
|
|
assert (
|
|
await instance_state(entry.runtime_data.database, instance.id)
|
|
== "open"
|
|
)
|
|
assert hass.bus.fired(EVENT_QUEST_COMPLETED) == []
|
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
|
assert refreshes == []
|
|
|
|
|
|
async def test_unconfigured_api_client_surfaces_as_a_service_error(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""An entry with no completion surface (the unconfigured-token
|
|
stub the coordinator swaps in) fails the service clearly instead
|
|
of reaching an attribute error."""
|
|
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
|
|
|
entry, _client, child, instance = await _setup_seeded_entry(
|
|
hass, make_entry, _UnconfiguredApiClient()
|
|
)
|
|
|
|
with pytest.raises(
|
|
HomeAssistantError, match="NestQuest API is not configured"
|
|
):
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": instance.id,
|
|
"actor": "panel",
|
|
"actor_child_id": child.id,
|
|
},
|
|
)
|
|
|
|
|
|
async def test_non_panel_actor_is_rejected_before_the_client(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""The API's panel route completes as the tapped child only: a
|
|
user-actor call fails fast with a clear error and never reaches
|
|
the client."""
|
|
entry, client, child, instance = await _setup_seeded_entry(
|
|
hass, make_entry, _RecordingApiClient(hass, "test_entry")
|
|
)
|
|
|
|
with pytest.raises(HomeAssistantError, match="actor must be 'panel'"):
|
|
await hass.services.call(
|
|
DOMAIN,
|
|
SERVICE_COMPLETE_QUEST,
|
|
{
|
|
"instance_id": instance.id,
|
|
"actor": "user",
|
|
"actor_user_id": ADMIN_CTX["user_id"],
|
|
},
|
|
context=ADMIN_CTX,
|
|
)
|
|
assert client.complete_calls == []
|