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 == []
|