complete_quest proxies to the API (task 47c7c90e) (#169)
This commit was merged in pull request #169.
This commit is contained in:
@@ -6,7 +6,10 @@ call time through the injected ``find_runtime`` callable. Permission
|
|||||||
checks are Feature 09 later tasks — this module only validates payload
|
checks are Feature 09 later tasks — this module only validates payload
|
||||||
shape and forwards to the Feature 03/06/08 business layers (the
|
shape and forwards to the Feature 03/06/08 business layers (the
|
||||||
presence writes included, through :mod:`.presence_management` — the
|
presence writes included, through :mod:`.presence_management` — the
|
||||||
ONE implementation the API admin plane shares).
|
ONE implementation the API admin plane shares). Since Feature 18 the
|
||||||
|
integration is a client of the NestQuest API service, so
|
||||||
|
``complete_quest`` proxies to the API's panel complete route through
|
||||||
|
the entry's shared API client instead of writing any database.
|
||||||
``export_history_csv`` is registered with a schema but raises until
|
``export_history_csv`` is registered with a schema but raises until
|
||||||
Feature 13. ``regenerate`` is supplied by the caller so
|
Feature 13. ``regenerate`` is supplied by the caller so
|
||||||
the Feature 07 handler stays the one in ``__init__``.
|
the Feature 07 handler stays the one in ``__init__``.
|
||||||
@@ -26,6 +29,7 @@ from . import children as children_layer
|
|||||||
from . import completion as completion_layer
|
from . import completion as completion_layer
|
||||||
from . import presence_management as presence_management_layer
|
from . import presence_management as presence_management_layer
|
||||||
from . import quest_definitions as quest_definitions_layer
|
from . import quest_definitions as quest_definitions_layer
|
||||||
|
from .api_client import NestQuestApiError
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DOMAIN_SERVICES,
|
DOMAIN_SERVICES,
|
||||||
@@ -42,7 +46,7 @@ from .const import (
|
|||||||
SERVICE_UNCOMPLETE_QUEST,
|
SERVICE_UNCOMPLETE_QUEST,
|
||||||
SERVICE_UPDATE_QUEST_DEFINITION,
|
SERVICE_UPDATE_QUEST_DEFINITION,
|
||||||
)
|
)
|
||||||
from .events import fire_quest_completed, fire_quest_uncompleted
|
from .events import fire_quest_uncompleted
|
||||||
from .permissions import permission_gate
|
from .permissions import permission_gate
|
||||||
from .recurrence import ScheduleRule
|
from .recurrence import ScheduleRule
|
||||||
from .service_policy import SERVICE_POLICY
|
from .service_policy import SERVICE_POLICY
|
||||||
@@ -301,22 +305,50 @@ def _complete_quest(
|
|||||||
async def _handler(call: Any) -> None:
|
async def _handler(call: Any) -> None:
|
||||||
runtime = _require_runtime(hass, find_runtime)
|
runtime = _require_runtime(hass, find_runtime)
|
||||||
instance_id = call.data["instance_id"]
|
instance_id = call.data["instance_id"]
|
||||||
# ``appended`` is decided under the same lock as the write, so
|
# The panel contract (decision 9): a tap completes as the
|
||||||
# a concurrent duplicate call (a double tap) can not produce a
|
# tapped child profile, the only actor shape the API's panel
|
||||||
# second transition event: the loser appends nothing.
|
# route accepts. The payload schema is unchanged — the panel
|
||||||
result = await completion_layer.complete_instance(
|
# cards call it with instance_id, actor=panel, actor_child_id.
|
||||||
runtime.database,
|
actor = _actor_kwargs(call)
|
||||||
instance_id,
|
if actor["actor_source"] != ACTOR_PANEL:
|
||||||
**_actor_kwargs(call),
|
raise HomeAssistantError(
|
||||||
)
|
"nestquest.complete_quest completes through the API's "
|
||||||
if result.appended:
|
"panel route as the tapped child profile; actor must "
|
||||||
await fire_quest_completed(
|
"be 'panel' with actor_child_id"
|
||||||
hass,
|
|
||||||
runtime.database,
|
|
||||||
instance_id,
|
|
||||||
was_on_time=result.was_on_time,
|
|
||||||
)
|
)
|
||||||
await _refresh_entities(runtime)
|
# The entry's ONE shared API client (built at setup): the
|
||||||
|
# coordinator's snapshot poll, the SSE subscription, and this
|
||||||
|
# proxy all ride the same instance. An entry with no panel
|
||||||
|
# token runs the unconfigured stub, which has no completion
|
||||||
|
# surface — fail fast with a clear, token-free message.
|
||||||
|
complete_instance = getattr(
|
||||||
|
getattr(runtime.coordinator, "api_client", None),
|
||||||
|
"complete_instance",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if complete_instance is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"NestQuest API is not configured; cannot complete a "
|
||||||
|
"quest"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await complete_instance(instance_id, actor["actor_child_id"])
|
||||||
|
except NestQuestApiError as err:
|
||||||
|
# The typed API failure surfaces as a service error the
|
||||||
|
# caller sees — never swallowed, never retried here. The
|
||||||
|
# typed message names method, path, and status only; the
|
||||||
|
# panel token never appears in it.
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"NestQuest quest completion failed: {err}"
|
||||||
|
) from err
|
||||||
|
# NO local completion write and NO local event: the API service
|
||||||
|
# emits the transition on its SSE stream and the integration's
|
||||||
|
# subscription re-fires it on the bus — firing it here too
|
||||||
|
# would double-fire. The immediate refresh keeps the old
|
||||||
|
# behaviour where the sensors reflect the completion the
|
||||||
|
# moment the service call returns instead of waiting for the
|
||||||
|
# poll interval.
|
||||||
|
await _refresh_entities(runtime)
|
||||||
|
|
||||||
return _with_errors(_handler)
|
return _with_errors(_handler)
|
||||||
|
|
||||||
|
|||||||
+127
-3
@@ -757,6 +757,16 @@ class LazyLocalSnapshotClient:
|
|||||||
coordinator reconstructs from a byte-identical payload without any
|
coordinator reconstructs from a byte-identical payload without any
|
||||||
network or FastAPI machinery.
|
network or FastAPI machinery.
|
||||||
|
|
||||||
|
``complete_instance`` bridges the panel complete route the same
|
||||||
|
way (the ``nestquest.complete_quest`` proxy calls it through the
|
||||||
|
API client): it resolves the entry's database and delegates to the
|
||||||
|
SAME core completion the API route delegates to, with the panel
|
||||||
|
actor shape, and answers the route's ``{"status": ...}`` body.
|
||||||
|
Like the real API service it publishes its transition events on
|
||||||
|
the SSE stream — NOT on the Home Assistant bus and NOT from the
|
||||||
|
service handler; tests that assert bus events drive them through
|
||||||
|
the SSE subscription the way production delivers them.
|
||||||
|
|
||||||
The resolution is LAZY on purpose: the coordinator's factory runs
|
The resolution is LAZY on purpose: the coordinator's factory runs
|
||||||
mid-setup, before the runtime record exists, so the first refresh
|
mid-setup, before the runtime record exists, so the first refresh
|
||||||
(and any refresh while the entry is torn down) serves an EMPTY but
|
(and any refresh while the entry is torn down) serves an EMPTY but
|
||||||
@@ -767,6 +777,29 @@ class LazyLocalSnapshotClient:
|
|||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._entry_id = entry_id
|
self._entry_id = entry_id
|
||||||
self.calls = 0
|
self.calls = 0
|
||||||
|
#: Every ``(instance_id, CompletionResult)`` this stand-in
|
||||||
|
# produced, in call order — tests read ``appended`` /
|
||||||
|
# ``was_on_time`` off these to check the completion verdicts.
|
||||||
|
self.completions = []
|
||||||
|
#: The SSE frames the stand-in published for its completions
|
||||||
|
# (the API service's stream content), drained by
|
||||||
|
#: :func:`refire_api_transitions`.
|
||||||
|
self.published_frames = []
|
||||||
|
|
||||||
|
def _live_runtime(self):
|
||||||
|
"""The entry's runtime record, or ``None`` before/after setup."""
|
||||||
|
from custom_components.nestquest.const import DOMAIN as NQ_DOMAIN
|
||||||
|
|
||||||
|
runtime = self._hass.data.get(NQ_DOMAIN, {}).get(self._entry_id)
|
||||||
|
database = getattr(runtime, "database", None)
|
||||||
|
if database is None or not getattr(database, "connected", True):
|
||||||
|
return None
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
def _live_database(self):
|
||||||
|
"""The entry's open database, or ``None`` before/after setup."""
|
||||||
|
runtime = self._live_runtime()
|
||||||
|
return None if runtime is None else runtime.database
|
||||||
|
|
||||||
async def get_snapshot(self):
|
async def get_snapshot(self):
|
||||||
import datetime
|
import datetime
|
||||||
@@ -776,14 +809,14 @@ class LazyLocalSnapshotClient:
|
|||||||
from custom_components.nestquest.core import snapshot as core_snapshot
|
from custom_components.nestquest.core import snapshot as core_snapshot
|
||||||
|
|
||||||
self.calls += 1
|
self.calls += 1
|
||||||
runtime = self._hass.data.get(NQ_DOMAIN, {}).get(self._entry_id)
|
runtime = self._live_runtime()
|
||||||
database = getattr(runtime, "database", None)
|
if runtime is None:
|
||||||
if database is None or not getattr(database, "connected", True):
|
|
||||||
return {
|
return {
|
||||||
"today_iso": datetime.date.today().isoformat(),
|
"today_iso": datetime.date.today().isoformat(),
|
||||||
"cycle_day": 0,
|
"cycle_day": 0,
|
||||||
"children": [],
|
"children": [],
|
||||||
}
|
}
|
||||||
|
database = runtime.database
|
||||||
settings = getattr(runtime, "settings", None)
|
settings = getattr(runtime, "settings", None)
|
||||||
# The HA-LOCAL now, the same single clock read the production
|
# The HA-LOCAL now, the same single clock read the production
|
||||||
# coordinator used to hand the builder: the hass stub's
|
# coordinator used to hand the builder: the hass stub's
|
||||||
@@ -813,6 +846,44 @@ class LazyLocalSnapshotClient:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def complete_instance(self, instance_id: int, actor_child_id: int):
|
||||||
|
"""The panel complete route's stand-in: complete through the
|
||||||
|
SAME core completion the route delegates to, panel actor
|
||||||
|
shape, publish the route's SSE transition frames (built by the
|
||||||
|
same core builders, appended completions only — see
|
||||||
|
``api/routes_panel.py``), and answer the route's
|
||||||
|
``{"status": ...}`` body."""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from custom_components.nestquest.completion import complete_instance
|
||||||
|
from custom_components.nestquest.core import events as core_events
|
||||||
|
|
||||||
|
result = await complete_instance(
|
||||||
|
self._live_database(),
|
||||||
|
instance_id,
|
||||||
|
actor_source="panel",
|
||||||
|
actor_child_id=actor_child_id,
|
||||||
|
)
|
||||||
|
self.completions.append((instance_id, result))
|
||||||
|
if result.appended:
|
||||||
|
# The route publishes the transition the moment the core
|
||||||
|
# write lands (appended completions only), evaluating the
|
||||||
|
# day-complete rule against the API host's local date —
|
||||||
|
# the frames queue here for the SSE stream to carry.
|
||||||
|
event_type, payload = await core_events.build_quest_completed_event(
|
||||||
|
self._live_database(),
|
||||||
|
instance_id,
|
||||||
|
was_on_time=result.was_on_time,
|
||||||
|
)
|
||||||
|
self.published_frames.append((event_type, payload))
|
||||||
|
now = datetime.datetime.now().astimezone()
|
||||||
|
day_complete = await core_events.build_child_day_complete_event(
|
||||||
|
self._live_database(), payload, today=now.date()
|
||||||
|
)
|
||||||
|
if day_complete is not None:
|
||||||
|
self.published_frames.append(day_complete)
|
||||||
|
return {"status": str(result)}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def coordinator_serves_local_snapshot():
|
def coordinator_serves_local_snapshot():
|
||||||
@@ -858,6 +929,59 @@ def set_coordinator_client(entry, client):
|
|||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
async def refire_api_transitions(hass, entry, client):
|
||||||
|
"""Deliver the SSE frames the stand-in client has published so far
|
||||||
|
through the integration's SSE subscription — the production event
|
||||||
|
path.
|
||||||
|
|
||||||
|
Since Feature 18 the ``complete_quest`` service fires no local
|
||||||
|
bus event: the API service builds and publishes the transition
|
||||||
|
frames on its SSE stream, and the integration's subscription
|
||||||
|
(:mod:`custom_components.nestquest.sse`) re-fires each frame on
|
||||||
|
the bus. This helper streams the frames the stand-in client
|
||||||
|
published (``client.published_frames``) through a real
|
||||||
|
:class:`~custom_components.nestquest.sse.NestQuestEventStream`
|
||||||
|
and waits (bounded) until the bus has re-fired every one of them.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from custom_components.nestquest.sse import NestQuestEventStream
|
||||||
|
|
||||||
|
frames, client.published_frames = client.published_frames, []
|
||||||
|
|
||||||
|
class _ScriptedStream:
|
||||||
|
"""A healthy stream: the frames, then it stays open."""
|
||||||
|
|
||||||
|
def __init__(self, frames) -> None:
|
||||||
|
self._frames = frames
|
||||||
|
|
||||||
|
def stream_events(self):
|
||||||
|
async def _gen():
|
||||||
|
for frame in self._frames:
|
||||||
|
yield frame
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
before = {etype: len(hass.bus.fired(etype)) for etype, _ in frames}
|
||||||
|
expected = len(frames)
|
||||||
|
manager = NestQuestEventStream(hass, _ScriptedStream(frames))
|
||||||
|
manager.start()
|
||||||
|
try:
|
||||||
|
async with asyncio.timeout(2):
|
||||||
|
while True:
|
||||||
|
delivered = sum(
|
||||||
|
len(hass.bus.fired(etype)) - before[etype]
|
||||||
|
for etype, _ in frames
|
||||||
|
)
|
||||||
|
if delivered >= expected:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
finally:
|
||||||
|
await manager.stop()
|
||||||
|
return frames
|
||||||
|
|
||||||
|
|
||||||
class _HassNamespace(SimpleNamespace):
|
class _HassNamespace(SimpleNamespace):
|
||||||
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
"""SimpleNamespace subclass so the hass stub can hold weak references."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""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 == []
|
||||||
@@ -14,7 +14,7 @@ from __future__ import annotations
|
|||||||
import datetime
|
import datetime
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
from conftest import refire_api_transitions, wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
from custom_components.nestquest import async_setup_entry
|
||||||
from custom_components.nestquest.children import (
|
from custom_components.nestquest.children import (
|
||||||
@@ -154,6 +154,11 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None:
|
|||||||
"actor_child_id": ada.id,
|
"actor_child_id": ada.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# The transition events arrive over the API's SSE stream (the
|
||||||
|
# subscription re-fires them), never from the service handler.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 2
|
||||||
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
for payload in hass.bus.fired(EVENT_QUEST_COMPLETED):
|
||||||
assert _TIMESTAMP.match(payload["occurred_at"])
|
assert _TIMESTAMP.match(payload["occurred_at"])
|
||||||
@@ -234,6 +239,10 @@ async def test_payload_schema_matches_the_documented_contract(
|
|||||||
"actor_child_id": ada.id,
|
"actor_child_id": ada.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# The completed event arrives over the API's SSE stream.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
payload = hass.bus.fired(EVENT_QUEST_COMPLETED)[0]
|
||||||
for key, check in (
|
for key, check in (
|
||||||
("child_id", lambda v: isinstance(v, int)),
|
("child_id", lambda v: isinstance(v, int)),
|
||||||
|
|||||||
+116
-26
@@ -1,9 +1,21 @@
|
|||||||
"""Tests for the NestQuest bus events (Feature 10, task 6)."""
|
"""Tests for the NestQuest bus events (Feature 10, task 6).
|
||||||
|
|
||||||
|
Since Feature 18 the integration is a client of the NestQuest API
|
||||||
|
service: ``nestquest.complete_quest`` proxies to the API's panel
|
||||||
|
complete route and fires NO local bus event — the API service builds
|
||||||
|
and publishes the transition frames on its SSE stream, and the
|
||||||
|
integration's subscription (:mod:`custom_components.nestquest.sse`)
|
||||||
|
re-fires them. The service-driven tests below therefore deliver the
|
||||||
|
frames through :func:`conftest.refire_api_transitions` (the
|
||||||
|
production event path, driven synchronously) before asserting the
|
||||||
|
bus; the shim-level tests drive :mod:`custom_components.nestquest
|
||||||
|
.events` directly, the way its remaining callers do.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from conftest import wire_entry_to_registry
|
from conftest import refire_api_transitions, wire_entry_to_registry
|
||||||
|
|
||||||
from custom_components.nestquest import async_setup_entry
|
from custom_components.nestquest import async_setup_entry
|
||||||
from custom_components.nestquest.children import create_child
|
from custom_components.nestquest.children import create_child
|
||||||
@@ -86,6 +98,11 @@ async def test_quest_completed_fires_with_documented_payload(
|
|||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||||
)
|
)
|
||||||
|
# The completed event arrives over the API's SSE stream (the
|
||||||
|
# subscription re-fires it), never from the service handler.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
fired = hass.bus.fired(EVENT_QUEST_COMPLETED)
|
fired = hass.bus.fired(EVENT_QUEST_COMPLETED)
|
||||||
assert len(fired) == 1
|
assert len(fired) == 1
|
||||||
payload = fired[0]
|
payload = fired[0]
|
||||||
@@ -111,6 +128,15 @@ async def test_recomplete_no_op_fires_nothing(hass, make_entry) -> None:
|
|||||||
SERVICE_COMPLETE_QUEST,
|
SERVICE_COMPLETE_QUEST,
|
||||||
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
{"instance_id": instance_id, "actor": "panel", "actor_child_id": child.id},
|
||||||
)
|
)
|
||||||
|
# The no-op verdict is decided in the API's completion layer: the
|
||||||
|
# second call appended nothing, so the API published no second
|
||||||
|
# frame and the bus stays at exactly one event.
|
||||||
|
client = entry.runtime_data.coordinator.api_client
|
||||||
|
assert [result.appended for _id, result in client.completions] == [
|
||||||
|
True,
|
||||||
|
False,
|
||||||
|
]
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +154,9 @@ async def test_child_day_complete_fires_when_day_clears(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
||||||
assert len(day_complete) == 1, "exactly one day-complete per cleared day"
|
assert len(day_complete) == 1, "exactly one day-complete per cleared day"
|
||||||
payload = day_complete[0]
|
payload = day_complete[0]
|
||||||
@@ -153,6 +182,12 @@ async def test_child_day_complete_does_not_fire_partway(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# The API publishes the completion but not a day-complete (the
|
||||||
|
# child's day is not clear yet); the subscription re-fires only
|
||||||
|
# what was published.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
assert hass.bus.fired(EVENT_CHILD_DAY_COMPLETE) == []
|
||||||
await hass.services.call(
|
await hass.services.call(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -163,6 +198,9 @@ async def test_child_day_complete_does_not_fire_partway(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -223,6 +261,15 @@ async def test_appended_verdict_is_atomic_under_duplicate_calls(
|
|||||||
for _ in range(2)
|
for _ in range(2)
|
||||||
]
|
]
|
||||||
await asyncio.gather(*calls)
|
await asyncio.gather(*calls)
|
||||||
|
# The API's completion layer (the same core lock) decides the
|
||||||
|
# verdict: one call appended, the duplicate did not — so the API
|
||||||
|
# published exactly one frame and the bus carries one event.
|
||||||
|
client = entry.runtime_data.coordinator.api_client
|
||||||
|
assert len(client.completions) == 2
|
||||||
|
assert [result.appended for _id, result in client.completions].count(
|
||||||
|
True
|
||||||
|
) == 1
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -263,6 +310,9 @@ async def test_day_complete_ignored_for_backdated_completion(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1
|
||||||
|
|
||||||
await _complete(
|
await _complete(
|
||||||
@@ -280,12 +330,19 @@ async def test_day_complete_ignored_for_backdated_completion(
|
|||||||
"actor_child_id": child.id,
|
"actor_child_id": child.id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
# The service call on the already-done future instance is a no-op
|
||||||
|
# inside the API's completion layer: nothing appended, nothing
|
||||||
|
# published, nothing re-fired — today's single announcement stands.
|
||||||
|
client = entry.runtime_data.coordinator.api_client
|
||||||
|
assert client.completions[-1][1].appended is False
|
||||||
|
await refire_api_transitions(hass, entry, client)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, (
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == 1, (
|
||||||
"a back-dated/future completion must not re-announce today"
|
"a back-dated/future completion must not re-announce today"
|
||||||
)
|
)
|
||||||
# The direct business-layer call fired nothing (only the service
|
# The direct business-layer call published nothing (only the API's
|
||||||
# path fires), and the follow-up service call on the now-done
|
# completion path publishes), and the follow-up service call on
|
||||||
# future instance was a no-op: today's single completion stands.
|
# the now-done future instance was a no-op: today's single
|
||||||
|
# completion stands.
|
||||||
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances)
|
assert len(hass.bus.fired(EVENT_QUEST_COMPLETED)) == len(instances)
|
||||||
|
|
||||||
|
|
||||||
@@ -324,6 +381,10 @@ async def test_completion_updates_entities_immediately(
|
|||||||
assert remaining_after == len(instances) - 1, (
|
assert remaining_after == len(instances) - 1, (
|
||||||
"sensor must reflect the completion without a manual refresh"
|
"sensor must reflect the completion without a manual refresh"
|
||||||
)
|
)
|
||||||
|
# The transition events arrive over the API's SSE stream.
|
||||||
|
await refire_api_transitions(
|
||||||
|
hass, entry, entry.runtime_data.coordinator.api_client
|
||||||
|
)
|
||||||
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == (
|
assert len(hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)) == (
|
||||||
1 if len(instances) == 1 else 0
|
1 if len(instances) == 1 else 0
|
||||||
)
|
)
|
||||||
@@ -538,17 +599,32 @@ async def test_shim_fires_completed_before_day_complete(hass, make_entry) -> Non
|
|||||||
"""The shim fires ``nestquest_quest_completed`` BEFORE
|
"""The shim fires ``nestquest_quest_completed`` BEFORE
|
||||||
``nestquest_child_day_complete`` (the pre-extraction ordering), so
|
``nestquest_child_day_complete`` (the pre-extraction ordering), so
|
||||||
a failure in the day-complete DB reads cannot suppress the
|
a failure in the day-complete DB reads cannot suppress the
|
||||||
completed event. Asserted by bus event ORDER, not just counts."""
|
completed event. Asserted by bus event ORDER, not just counts.
|
||||||
|
|
||||||
|
Driven directly: since Feature 18 the completion service proxies
|
||||||
|
to the API (whose event ordering is tested in test_api_sse.py);
|
||||||
|
this targets the shim module itself, the way its remaining
|
||||||
|
callers invoke it.
|
||||||
|
"""
|
||||||
|
from custom_components.nestquest.completion import (
|
||||||
|
complete_instance as _complete,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.events import fire_quest_completed
|
||||||
|
|
||||||
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
entry, child, instances = await _setup_and_seed(hass, make_entry)
|
||||||
|
database = entry.runtime_data.database
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
await hass.services.call(
|
result = await _complete(
|
||||||
DOMAIN,
|
database,
|
||||||
SERVICE_COMPLETE_QUEST,
|
instance.id,
|
||||||
{
|
actor_source="panel",
|
||||||
"instance_id": instance.id,
|
actor_child_id=child.id,
|
||||||
"actor": "panel",
|
)
|
||||||
"actor_child_id": child.id,
|
await fire_quest_completed(
|
||||||
},
|
hass,
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
was_on_time=result.was_on_time,
|
||||||
)
|
)
|
||||||
ordered = [
|
ordered = [
|
||||||
etype for etype, _payload in hass.bus.events
|
etype for etype, _payload in hass.bus.events
|
||||||
@@ -732,21 +808,31 @@ async def test_day_complete_fully_cleared_today_yields_event(
|
|||||||
async def test_shim_day_complete_uses_ha_local_timezone(
|
async def test_shim_day_complete_uses_ha_local_timezone(
|
||||||
hass, make_entry
|
hass, make_entry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""The day-complete rule reads ``hass.config.time_zone`` and
|
"""The shim's day-complete rule reads ``hass.config.time_zone``
|
||||||
derives ``today`` in HA-local time, not UTC.
|
and derives ``today`` in HA-local time, not UTC.
|
||||||
|
|
||||||
Picks a zone whose current local date differs from UTC, seeds an
|
Picks a zone whose current local date differs from UTC, seeds an
|
||||||
instance for THAT zone's today (not UTC's today), completes it
|
instance for THAT zone's today (not UTC's today), fires the shim
|
||||||
through the service path, and asserts
|
directly for a completion of that instance, and asserts
|
||||||
``nestquest_child_day_complete`` fires — proving the local date
|
``nestquest_child_day_complete`` fires — proving the local date
|
||||||
reached the day-complete rule. If no candidate zone has a
|
reached the day-complete rule. If no candidate zone has a
|
||||||
different date right now (only possible inside the ~1h window
|
different date right now (only possible inside the ~1h window
|
||||||
around 12:00 UTC when all zones share one date), the test skips
|
around 12:00 UTC when all zones share one date), the test skips
|
||||||
rather than run vacuously.
|
rather than run vacuously.
|
||||||
|
|
||||||
|
Driven directly: since Feature 18 the completion service proxies
|
||||||
|
to the API (whose day-complete ``today`` is the API host's local
|
||||||
|
date, tested in test_api_sse.py); this targets the shim module
|
||||||
|
itself, the way its remaining callers invoke it.
|
||||||
"""
|
"""
|
||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from custom_components.nestquest.completion import (
|
||||||
|
complete_instance as _complete,
|
||||||
|
)
|
||||||
|
from custom_components.nestquest.events import fire_quest_completed
|
||||||
|
|
||||||
utc_today = _dt.datetime.now(_dt.timezone.utc).date()
|
utc_today = _dt.datetime.now(_dt.timezone.utc).date()
|
||||||
far_zone = None
|
far_zone = None
|
||||||
far_today = None
|
far_today = None
|
||||||
@@ -775,15 +861,19 @@ async def test_shim_day_complete_uses_ha_local_timezone(
|
|||||||
entry, child, instances = await _setup_and_seed(
|
entry, child, instances = await _setup_and_seed(
|
||||||
hass, make_entry, seed_date=far_today
|
hass, make_entry, seed_date=far_today
|
||||||
)
|
)
|
||||||
|
database = entry.runtime_data.database
|
||||||
for instance in instances:
|
for instance in instances:
|
||||||
await hass.services.call(
|
result = await _complete(
|
||||||
DOMAIN,
|
database,
|
||||||
SERVICE_COMPLETE_QUEST,
|
instance.id,
|
||||||
{
|
actor_source="panel",
|
||||||
"instance_id": instance.id,
|
actor_child_id=child.id,
|
||||||
"actor": "panel",
|
)
|
||||||
"actor_child_id": child.id,
|
await fire_quest_completed(
|
||||||
},
|
hass,
|
||||||
|
database,
|
||||||
|
instance.id,
|
||||||
|
was_on_time=result.was_on_time,
|
||||||
)
|
)
|
||||||
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
day_complete = hass.bus.fired(EVENT_CHILD_DAY_COMPLETE)
|
||||||
assert len(day_complete) == 1, (
|
assert len(day_complete) == 1, (
|
||||||
|
|||||||
Reference in New Issue
Block a user