Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com> Co-committed-by: dev_agent <dev_agent@cubecraftlabs.com>
831 lines
31 KiB
Python
831 lines
31 KiB
Python
"""Tests for the shared NestQuest coordinator (Feature 10, Feature 18).
|
|
|
|
The coordinator now polls the API service's panel snapshot through the
|
|
API client (Feature 18) and reconstructs the core snapshot dataclasses
|
|
every entity reads. The tests stub the client — NO network, NO aiohttp
|
|
— with a FIXTURE snapshot payload (the documented shape of the Feature
|
|
16 snapshot route, ``api/routes_panel.py``) and assert the
|
|
reconstruction: per-child counts and presence, the instance views
|
|
(``completed`` mapped back to ``done``), the household rollups, the
|
|
cycle day, and the unconfigured / failed-fetch behaviour.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import logging
|
|
|
|
import pytest
|
|
|
|
from conftest import set_coordinator_client, wire_entry_to_registry
|
|
|
|
from custom_components.nestquest import async_setup_entry, async_unload_entry
|
|
from custom_components.nestquest.api_client import (
|
|
NestQuestApiClient,
|
|
NestQuestApiError,
|
|
)
|
|
from custom_components.nestquest.const import (
|
|
CONF_SNAPSHOT_STALENESS,
|
|
CONF_UPDATE_INTERVAL,
|
|
DEFAULT_SNAPSHOT_STALENESS,
|
|
DEFAULT_UPDATE_INTERVAL,
|
|
DOMAIN,
|
|
)
|
|
from custom_components.nestquest.coordinator import (
|
|
NestQuestCoordinator,
|
|
coordinator_client_from_entry,
|
|
snapshot_from_api_payload,
|
|
)
|
|
from custom_components.nestquest.core.snapshot import instance_payload
|
|
from homeassistant.helpers.update_coordinator import UpdateFailed
|
|
|
|
|
|
def _today_iso() -> str:
|
|
return datetime.date.today().isoformat()
|
|
|
|
|
|
class StubSnapshotClient:
|
|
"""A no-network stand-in for the API client's snapshot surface.
|
|
|
|
Answers ``get_snapshot()`` from a scripted queue of outcomes — a
|
|
payload dict or an exception per call, consumed in order — and
|
|
records every call, so each test drives exactly one refresh.
|
|
"""
|
|
|
|
def __init__(self, *outcomes) -> None:
|
|
self.calls = 0
|
|
self._outcomes = list(outcomes)
|
|
self._last = outcomes[-1] if outcomes else None
|
|
|
|
async def get_snapshot(self):
|
|
self.calls += 1
|
|
if not self._outcomes:
|
|
# Re-serve the last outcome: setup's first refresh and the
|
|
# test's explicit pass both poll the same scripted state.
|
|
outcome = self._last
|
|
else:
|
|
outcome = self._outcomes.pop(0)
|
|
self._last = outcome
|
|
if isinstance(outcome, BaseException):
|
|
raise outcome
|
|
return outcome
|
|
|
|
|
|
class FakeMonotonic:
|
|
"""A deterministic stand-in for the staleness clock.
|
|
|
|
The coordinator reads elapsed time through
|
|
:func:`custom_components.nestquest.coordinator._monotonic`; tests
|
|
patch that seam with an instance of this class and advance ``now``
|
|
by whole seconds, so the staleness window is exercised without any
|
|
real waiting.
|
|
"""
|
|
|
|
def __init__(self, start: float = 1000.0) -> None:
|
|
self.now = start
|
|
|
|
def __call__(self) -> float:
|
|
return self.now
|
|
|
|
|
|
#: A fixture panel snapshot (the documented Feature 16 route shape):
|
|
#: two children — Ada owes one, owes none of it yet, is away today with
|
|
#: a returns date; Bo completed his only quest and is present.
|
|
FIXTURE_SNAPSHOT = {
|
|
"today_iso": _today_iso(),
|
|
"cycle_day": 3,
|
|
"timezone": "",
|
|
"children": [
|
|
{
|
|
"child_id": 1,
|
|
"child_name": "Ada",
|
|
"present": False,
|
|
"next_present": "2026-09-24",
|
|
"due_today": 2,
|
|
"completed_today": 1,
|
|
"remaining_today": 1,
|
|
"completion_pct": 50,
|
|
"instances": [
|
|
{
|
|
"id": 7,
|
|
"definition_id": 3,
|
|
"child_id": 1,
|
|
"title": "Brush teeth",
|
|
"icon": "🦷",
|
|
"window": "morning",
|
|
"due_time": "08:00",
|
|
"state": "completed",
|
|
"overdue": False,
|
|
"completed_at": f"{_today_iso()}T07:55:00+00:00",
|
|
"on_time": True,
|
|
},
|
|
{
|
|
"id": 8,
|
|
"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,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"child_id": 2,
|
|
"child_name": "Bo",
|
|
"present": True,
|
|
"next_present": None,
|
|
"due_today": 1,
|
|
"completed_today": 1,
|
|
"remaining_today": 0,
|
|
"completion_pct": 100,
|
|
"instances": [
|
|
{
|
|
"id": 9,
|
|
"definition_id": 4,
|
|
"child_id": 2,
|
|
"title": "Make bed",
|
|
"icon": "🛏",
|
|
"window": "morning",
|
|
"due_time": "07:30",
|
|
"state": "completed",
|
|
"overdue": False,
|
|
"completed_at": f"{_today_iso()}T07:31:00+00:00",
|
|
"on_time": True,
|
|
}
|
|
],
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
async def _wire_entry(hass, make_entry, client, options=None):
|
|
"""Build an entry whose coordinator uses the scripted API client.
|
|
|
|
The client is registered with the conftest override BEFORE setup:
|
|
the first refresh at setup already polls the snapshot, so the
|
|
platform setup sees real data and creates the whole entity set.
|
|
"""
|
|
from conftest import set_coordinator_client
|
|
|
|
entry = wire_entry_to_registry(
|
|
make_entry(
|
|
options=options,
|
|
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"},
|
|
),
|
|
hass.registry,
|
|
)
|
|
set_coordinator_client(entry, client)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
assert coordinator.api_client is client
|
|
return entry, coordinator
|
|
|
|
|
|
async def test_coordinator_builds_snapshot_from_fixture(hass, make_entry) -> None:
|
|
"""One refresh = one snapshot GET; the fixture's values land on the
|
|
snapshot the entities read, per child and household-wide."""
|
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
|
|
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
|
calls_after_setup = client.calls
|
|
snapshot = await coordinator._async_update_data()
|
|
|
|
assert client.calls == calls_after_setup + 1
|
|
assert snapshot.today_iso == _today_iso()
|
|
assert snapshot.cycle_day == 3
|
|
assert len(snapshot.children) == 2
|
|
|
|
ada, bo = snapshot.children
|
|
assert (ada.child_id, ada.child_name) == (1, "Ada")
|
|
assert ada.due_today == 2
|
|
assert ada.completed_today == 1
|
|
assert ada.remaining_today == 1
|
|
assert ada.completion_pct == 50
|
|
assert ada.present is False
|
|
assert ada.next_present == "2026-09-24"
|
|
|
|
done_view, open_view = ada.instances
|
|
assert done_view.instance_id == 7
|
|
assert done_view.title == "Brush teeth"
|
|
assert done_view.icon == "🦷"
|
|
assert done_view.window == "morning"
|
|
assert done_view.due_time == "08:00"
|
|
assert done_view.due_date == _today_iso()
|
|
# The API's "completed" maps back to the core view's "done".
|
|
assert done_view.state == "done"
|
|
assert done_view.overdue is False
|
|
assert done_view.completed_at == f"{_today_iso()}T07:55:00+00:00"
|
|
assert done_view.was_on_time is True
|
|
|
|
assert open_view.state == "open"
|
|
assert open_view.overdue is True
|
|
assert open_view.completed_at is None
|
|
assert open_view.was_on_time is None
|
|
|
|
assert (bo.child_id, bo.child_name) == (2, "Bo")
|
|
assert bo.due_today == 1
|
|
assert bo.completed_today == 1
|
|
assert bo.remaining_today == 0
|
|
assert bo.completion_pct == 100
|
|
assert bo.present is True
|
|
assert bo.next_present is None
|
|
|
|
# Household rollups: the sums the household sensors compute.
|
|
assert sum(child.due_today for child in snapshot.children) == 3
|
|
assert sum(child.completed_today for child in snapshot.children) == 2
|
|
assert sum(child.remaining_today for child in snapshot.children) == 1
|
|
|
|
|
|
async def test_coordinator_refresh_publishes_to_listeners(hass, make_entry) -> None:
|
|
"""The refresh pass notifies listeners and reports success — the
|
|
ONE refresh pass every entity reads from."""
|
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
|
|
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
|
called = []
|
|
coordinator.async_add_listener(lambda: called.append("entity"))
|
|
await coordinator.async_refresh()
|
|
assert called == ["entity"]
|
|
assert coordinator.last_update_success is True
|
|
assert coordinator.data is not None
|
|
assert coordinator.data.children[0].child_name == "Ada"
|
|
|
|
|
|
async def test_instance_payload_shape_is_unchanged(hass, make_entry) -> None:
|
|
"""The per-child instance attribute payload the due-today sensor
|
|
emits keeps its exact documented shape, and admin_instances now
|
|
equals instances (the API omits missed rows — D-009; the admin
|
|
surface for missed quests is the PWA)."""
|
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
|
|
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
|
snapshot = await coordinator._async_update_data()
|
|
ada = snapshot.children[0]
|
|
|
|
panel = instance_payload(ada.instances, include_missed=False)
|
|
assert panel == [
|
|
{
|
|
"id": 7,
|
|
"definition_id": 3,
|
|
"child_id": 1,
|
|
"title": "Brush teeth",
|
|
"icon": "🦷",
|
|
"window": "morning",
|
|
"due_time": "08:00",
|
|
"state": "completed",
|
|
"overdue": False,
|
|
"completed_at": f"{_today_iso()}T07:55:00+00:00",
|
|
"on_time": True,
|
|
},
|
|
{
|
|
"id": 8,
|
|
"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,
|
|
},
|
|
]
|
|
# The API payload omits missed rows, so include_missed cannot
|
|
# re-add them: admin_instances == instances, byte for byte.
|
|
assert instance_payload(ada.instances, include_missed=True) == panel
|
|
|
|
|
|
async def test_coordinator_entities_read_the_api_snapshot(hass, make_entry) -> None:
|
|
"""The registered sensors and binary sensors read the rebuilt
|
|
snapshot: entity values come from the fixture, with the SAME
|
|
entity_ids as ever."""
|
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT)
|
|
entry, coordinator = await _wire_entry(hass, make_entry, client)
|
|
await coordinator.async_refresh()
|
|
|
|
ada_due = hass.entities[f"{DOMAIN}_child_1_quests_due_today"]
|
|
assert ada_due.native_value == 2
|
|
attributes = ada_due.extra_state_attributes
|
|
assert attributes["child_id"] == 1
|
|
assert attributes["child_name"] == "Ada"
|
|
assert attributes["present"] is False
|
|
assert [row["id"] for row in attributes["instances"]] == [7, 8]
|
|
|
|
assert (
|
|
hass.entities[f"{DOMAIN}_child_1_quests_completed_today"].native_value
|
|
== 1
|
|
)
|
|
assert (
|
|
hass.entities[f"{DOMAIN}_child_1_quests_remaining_today"].native_value
|
|
== 1
|
|
)
|
|
assert (
|
|
hass.entities[f"{DOMAIN}_child_1_completion_pct_today"].native_value
|
|
== 50
|
|
)
|
|
assert hass.entities[f"{DOMAIN}_child_1_next_quest"].native_value == (
|
|
"Pack bag"
|
|
)
|
|
# Ada owes one still: not all done; away today.
|
|
assert hass.entities[f"{DOMAIN}_child_1_all_done"].is_on is False
|
|
assert hass.entities[f"{DOMAIN}_child_1_present_today"].is_on is False
|
|
|
|
assert (
|
|
hass.entities[f"{DOMAIN}_child_2_completion_pct_today"].native_value
|
|
== 100
|
|
)
|
|
# Bo had a quest and owes none of it: all done, present today.
|
|
assert hass.entities[f"{DOMAIN}_child_2_all_done"].is_on is True
|
|
assert hass.entities[f"{DOMAIN}_child_2_present_today"].is_on is True
|
|
bo_attributes = hass.entities[
|
|
f"{DOMAIN}_child_2_present_today"
|
|
].extra_state_attributes
|
|
assert bo_attributes["next_present"] is None
|
|
|
|
assert (
|
|
hass.entities[f"{DOMAIN}_household_quests_due_today"].native_value == 3
|
|
)
|
|
assert (
|
|
hass.entities[f"{DOMAIN}_household_quests_completed_today"].native_value
|
|
== 2
|
|
)
|
|
cycle = hass.entities[f"{DOMAIN}_cycle_day"]
|
|
assert cycle.native_value == 3
|
|
assert cycle.extra_state_attributes == {
|
|
"cycle_day": 3,
|
|
"today": _today_iso(),
|
|
}
|
|
|
|
await async_unload_entry(hass, entry)
|
|
|
|
|
|
async def test_coordinator_failed_fetch_marks_unavailable(
|
|
hass, make_entry, monkeypatch
|
|
) -> None:
|
|
"""A failed fetch (transport failure or non-2xx — both typed) PAST
|
|
the staleness threshold is a FAILED refresh pass: the update pass
|
|
converts the typed error to UpdateFailed (the exception HA's
|
|
coordinator machinery records as a failed pass, chaining the typed
|
|
error as ``__cause__``), the refresh records the failure instead of
|
|
raising, and data keeps its last good value. (Within the threshold
|
|
the last-good cache serves instead — the staleness tests below.)"""
|
|
import custom_components.nestquest.coordinator as coordinator_module
|
|
|
|
clock = FakeMonotonic()
|
|
monkeypatch.setattr(coordinator_module, "_monotonic", clock)
|
|
error = NestQuestApiError(
|
|
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
|
|
status=503,
|
|
)
|
|
# The stub serves the good snapshot for setup's first refresh, then
|
|
# the scripted failure.
|
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT, error)
|
|
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
|
# First refresh (at setup) served the good snapshot.
|
|
assert coordinator.data is not None
|
|
assert coordinator.last_update_success is True
|
|
|
|
# The next pass hits the scripted failure AFTER the cache went
|
|
# stale — one second past the shipped 900 s default threshold: the
|
|
# update pass converts the typed error to UpdateFailed, with the
|
|
# typed error chained.
|
|
clock.now += DEFAULT_SNAPSHOT_STALENESS + 1
|
|
with pytest.raises(UpdateFailed, match="HTTP 503") as excinfo:
|
|
await coordinator._async_update_data()
|
|
assert isinstance(excinfo.value.__cause__, NestQuestApiError)
|
|
|
|
# The refresh pass records the failure the HA way — it does NOT
|
|
# raise: last_update_success goes False, data keeps the last GOOD
|
|
# snapshot, and the failed pass lands on last_exception as the
|
|
# converted UpdateFailed with the typed error chained.
|
|
await coordinator.async_refresh()
|
|
assert coordinator.last_update_success is False
|
|
assert coordinator.data.children[0].child_name == "Ada"
|
|
assert isinstance(coordinator.last_exception, UpdateFailed)
|
|
assert isinstance(coordinator.last_exception.__cause__, NestQuestApiError)
|
|
|
|
|
|
async def test_coordinator_serves_cached_snapshot_within_staleness(
|
|
hass, make_entry, monkeypatch, caplog
|
|
) -> None:
|
|
"""API down WITHIN the staleness threshold: the failed fetch serves
|
|
the last-good cache — the pass succeeds with the SAME snapshot, so
|
|
``last_update_success`` stays True (entities remain available) and
|
|
the outage is logged as a clear warning, never silently."""
|
|
import custom_components.nestquest.coordinator as coordinator_module
|
|
|
|
clock = FakeMonotonic()
|
|
monkeypatch.setattr(coordinator_module, "_monotonic", clock)
|
|
error = NestQuestApiError(
|
|
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
|
|
status=503,
|
|
)
|
|
# Setup's first refresh consumes the good snapshot; the two test
|
|
# passes hit the scripted failure.
|
|
client = StubSnapshotClient(FIXTURE_SNAPSHOT, error, error)
|
|
_entry, coordinator = await _wire_entry(
|
|
hass,
|
|
make_entry,
|
|
client,
|
|
options={CONF_UPDATE_INTERVAL: 300, CONF_SNAPSHOT_STALENESS: 900},
|
|
)
|
|
# The successful first pass cached the snapshot (stamped at the
|
|
# fake clock's start).
|
|
first = coordinator.data
|
|
assert first is not None
|
|
assert coordinator.last_update_success is True
|
|
|
|
# The API goes down 900 s after that success — exactly the
|
|
# threshold, still WITHIN the inclusive window: the pass serves the
|
|
# cached snapshot instead of failing.
|
|
clock.now += 900
|
|
with caplog.at_level(
|
|
logging.WARNING, logger="custom_components.nestquest"
|
|
):
|
|
await coordinator.async_refresh()
|
|
assert coordinator.last_update_success is True
|
|
assert coordinator.data is first
|
|
assert coordinator.data.children[0].child_name == "Ada"
|
|
# The fetch WAS attempted (and failed) — the cache is a fallback,
|
|
# not a replacement for polling.
|
|
assert client.calls == 2
|
|
assert any(
|
|
"last-good" in record.getMessage() for record in caplog.records
|
|
)
|
|
|
|
|
|
async def test_coordinator_recovers_without_reload_after_outage(
|
|
hass, make_entry, monkeypatch
|
|
) -> None:
|
|
"""The full outage arc: within the threshold the cache serves, past
|
|
it the entities go unavailable, and when the API comes back the
|
|
next pass succeeds with the FRESH snapshot, re-arms the cache, and
|
|
the entities recover — without any config-entry reload."""
|
|
import custom_components.nestquest.coordinator as coordinator_module
|
|
|
|
clock = FakeMonotonic()
|
|
monkeypatch.setattr(coordinator_module, "_monotonic", clock)
|
|
outage = NestQuestApiError(
|
|
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
|
|
status=503,
|
|
)
|
|
post_recovery_outage = NestQuestApiError(
|
|
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 502",
|
|
status=502,
|
|
)
|
|
fresh = {**FIXTURE_SNAPSHOT, "cycle_day": 4}
|
|
client = StubSnapshotClient(
|
|
FIXTURE_SNAPSHOT, outage, outage, fresh, post_recovery_outage
|
|
)
|
|
_entry, coordinator = await _wire_entry(
|
|
hass,
|
|
make_entry,
|
|
client,
|
|
options={CONF_UPDATE_INTERVAL: 300, CONF_SNAPSHOT_STALENESS: 900},
|
|
)
|
|
first = coordinator.data
|
|
assert first.cycle_day == 3
|
|
|
|
# Within the threshold: the outage serves the cache.
|
|
clock.now += 300
|
|
await coordinator.async_refresh()
|
|
assert coordinator.last_update_success is True
|
|
assert coordinator.data is first
|
|
|
|
# Past the threshold: the outage fails the pass (unavailable).
|
|
clock.now += 901
|
|
await coordinator.async_refresh()
|
|
assert coordinator.last_update_success is False
|
|
|
|
# The API returns: the next pass succeeds with the fresh snapshot
|
|
# and no reload was involved anywhere.
|
|
await coordinator.async_refresh()
|
|
assert coordinator.last_update_success is True
|
|
fresh_data = coordinator.data
|
|
assert fresh_data.cycle_day == 4
|
|
assert hass.registry.reloaded == []
|
|
|
|
# The success re-armed the cache with the FRESH stamp: another
|
|
# outage within the threshold serves the new snapshot now, not the
|
|
# pre-outage one.
|
|
clock.now += 300
|
|
await coordinator.async_refresh()
|
|
assert coordinator.last_update_success is True
|
|
assert coordinator.data is fresh_data
|
|
assert client.calls == 5
|
|
|
|
|
|
async def test_setup_creates_coordinator_with_configured_staleness(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""The staleness threshold rides the entry options into the
|
|
coordinator, alongside the update interval."""
|
|
entry = wire_entry_to_registry(
|
|
make_entry(
|
|
options={CONF_UPDATE_INTERVAL: 60, CONF_SNAPSHOT_STALENESS: 600}
|
|
),
|
|
hass.registry,
|
|
)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
assert coordinator._staleness_seconds == 600
|
|
|
|
|
|
async def test_setup_staleness_below_interval_falls_back_to_default(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A stored threshold below the update interval (a raw hand-edited
|
|
option) never reaches the coordinator as-is: it falls back to the
|
|
shipped default, which still spans the configured interval."""
|
|
entry = wire_entry_to_registry(
|
|
make_entry(
|
|
options={CONF_UPDATE_INTERVAL: 600, CONF_SNAPSHOT_STALENESS: 60}
|
|
),
|
|
hass.registry,
|
|
)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
assert coordinator._staleness_seconds == DEFAULT_SNAPSHOT_STALENESS
|
|
|
|
|
|
async def test_coordinator_staleness_floor_never_below_interval(hass) -> None:
|
|
"""A threshold below the interval falls back to whichever of
|
|
default/interval is larger, so the cache can always span at least
|
|
one failed poll (here the interval exceeds the shipped default)."""
|
|
coordinator = NestQuestCoordinator(
|
|
hass,
|
|
entry_id="floor",
|
|
api_client=StubSnapshotClient(),
|
|
update_interval_seconds=1800,
|
|
staleness_seconds=60,
|
|
)
|
|
assert coordinator._staleness_seconds == 1800
|
|
|
|
|
|
async def test_failed_first_refresh_does_not_crash_setup(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""A failed fetch at the FIRST refresh (at setup) does not crash
|
|
setup: the coordinator reports last_update_success=False, the setup
|
|
catches the ConfigEntryNotReady that HA's real first refresh raises
|
|
for a failed pass, and the platforms still forward so the entities
|
|
come up unavailable."""
|
|
# The stub fails every snapshot fetch (HTTP 503), including setup's
|
|
# first refresh.
|
|
client = StubSnapshotClient(
|
|
NestQuestApiError(
|
|
"NestQuest API GET /api/v1/panel/snapshot failed with HTTP 503",
|
|
status=503,
|
|
)
|
|
)
|
|
entry = wire_entry_to_registry(
|
|
make_entry(
|
|
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
|
),
|
|
hass.registry,
|
|
)
|
|
set_coordinator_client(entry, client)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
assert coordinator.api_client is client
|
|
# The failed first pass: the entities' surface stays unavailable.
|
|
assert coordinator.last_update_success is False
|
|
assert coordinator.data is None
|
|
# Setup survived the failed first refresh — the platforms were
|
|
# forwarded (the regression the review caught: a first-refresh
|
|
# failure used to abort setup before any entity was created).
|
|
assert hass.config_entries.forwarded_platforms == [
|
|
"sensor",
|
|
"binary_sensor",
|
|
]
|
|
# The unload after the failed-first-refresh setup stays clean.
|
|
assert await async_unload_entry(hass, entry) is True
|
|
|
|
|
|
async def test_coordinator_unconfigured_api_does_not_crash_setup(
|
|
hass, make_entry, monkeypatch
|
|
) -> None:
|
|
"""An entry with no panel token set up fine: the coordinator gets
|
|
the typed-error stub, the first refresh fails cleanly (unavailable
|
|
entities), and setup does NOT crash."""
|
|
# Restore the production factory: the conftest default would serve
|
|
# the local snapshot, but this test exercises the token guard.
|
|
import custom_components.nestquest as nq
|
|
|
|
monkeypatch.setattr(
|
|
nq, "coordinator_client_from_entry", coordinator_client_from_entry
|
|
)
|
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
from custom_components.nestquest.coordinator import _UnconfiguredApiClient
|
|
|
|
assert isinstance(coordinator.api_client, _UnconfiguredApiClient)
|
|
# A real NestQuestApiClient was never built (it would ValueError).
|
|
assert not isinstance(coordinator.api_client, NestQuestApiClient)
|
|
# The first refresh failed the HA way: the update pass converted
|
|
# the stub's typed error to UpdateFailed, the first refresh raised
|
|
# ConfigEntryNotReady, and setup tolerated it — platforms forwarded
|
|
# so the entities come up unavailable.
|
|
assert coordinator.last_update_success is False
|
|
assert coordinator.data is None
|
|
with pytest.raises(UpdateFailed, match="not configured"):
|
|
await coordinator._async_update_data()
|
|
assert hass.config_entries.forwarded_platforms == [
|
|
"sensor",
|
|
"binary_sensor",
|
|
]
|
|
assert await async_unload_entry(hass, entry) is True
|
|
|
|
|
|
async def test_coordinator_configured_token_builds_real_client(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""An entry WITH a panel token builds the real client through the
|
|
production factory (a stub transport injected via session_factory,
|
|
the mock-only harness having no aiohttp module to hand in), and the
|
|
coordinator's refresh polls the snapshot route end to end."""
|
|
import json as json_module
|
|
|
|
class _OneShotSession:
|
|
"""A session-shaped stub answering every request with the fixture."""
|
|
|
|
def __init__(self, payload) -> None:
|
|
self._body = json_module.dumps(payload)
|
|
|
|
def request(self, method, url, **kwargs):
|
|
body = self._body
|
|
|
|
class _Response:
|
|
def __init__(self) -> None:
|
|
self.status = 200
|
|
|
|
async def text(self):
|
|
return body
|
|
|
|
class _Context:
|
|
async def __aenter__(self):
|
|
return _Response()
|
|
|
|
async def __aexit__(self, *exc):
|
|
return False
|
|
|
|
return _Context()
|
|
|
|
def _session_factory(_hass):
|
|
return _OneShotSession(FIXTURE_SNAPSHOT)
|
|
|
|
entry = wire_entry_to_registry(
|
|
make_entry(
|
|
data={"api_base_url": "http://api.test:8000", "panel_token": "tok"}
|
|
),
|
|
hass.registry,
|
|
)
|
|
real_client = coordinator_client_from_entry(
|
|
hass, entry, session_factory=_session_factory
|
|
)
|
|
assert isinstance(real_client, NestQuestApiClient)
|
|
# Home Assistant's zone is not reported to the API (task 7f685179).
|
|
assert real_client._headers() == {"Authorization": "Bearer tok"}
|
|
|
|
set_coordinator_client(entry, real_client)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
assert isinstance(coordinator.api_client, NestQuestApiClient)
|
|
# The real client polls the stub transport; the snapshot lands.
|
|
await coordinator.async_refresh()
|
|
assert coordinator.data.children[0].child_name == "Ada"
|
|
|
|
|
|
async def test_setup_creates_coordinator_with_configured_interval(
|
|
hass, make_entry
|
|
) -> None:
|
|
entry = wire_entry_to_registry(
|
|
make_entry(options={CONF_UPDATE_INTERVAL: 60}), hass.registry
|
|
)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
assert isinstance(coordinator, NestQuestCoordinator)
|
|
assert coordinator.update_interval == datetime.timedelta(seconds=60)
|
|
|
|
|
|
async def test_setup_defaults_to_five_minute_interval(hass, make_entry) -> None:
|
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
assert coordinator.update_interval == datetime.timedelta(
|
|
seconds=DEFAULT_UPDATE_INTERVAL
|
|
)
|
|
|
|
|
|
async def test_unload_shuts_coordinator_down(hass, make_entry) -> None:
|
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
|
assert await async_setup_entry(hass, entry) is True
|
|
coordinator = entry.runtime_data.coordinator
|
|
torn_down = []
|
|
|
|
async def _shutdown():
|
|
torn_down.append(coordinator)
|
|
|
|
coordinator.async_shutdown = _shutdown
|
|
assert await async_unload_entry(hass, entry) is True
|
|
assert torn_down == [coordinator]
|
|
|
|
|
|
async def test_setup_failure_after_registration_shuts_coordinator_down(
|
|
hass, make_entry, monkeypatch
|
|
) -> None:
|
|
"""A setup failure after coordinator creation must not leak its
|
|
listeners or leave a stale runtime record behind.
|
|
|
|
The runtime record registers in ``hass.data`` BEFORE the platform
|
|
forward, so a forward failure reaches the unwind with the record
|
|
already registered. The unwind must shut the coordinator down,
|
|
clear ``entry.runtime_data``, AND remove the stale ``hass.data``
|
|
entry — otherwise a later unload picks up the stale, already-torn-
|
|
down record and re-invokes the already-consumed update-listener
|
|
remover, which raises. (A failed first refresh is NOT such a
|
|
failure: HA's real first refresh wraps it in ConfigEntryNotReady,
|
|
which setup deliberately tolerates so entities come up
|
|
unavailable.)
|
|
"""
|
|
from custom_components.nestquest import async_setup_entry
|
|
import custom_components.nestquest.coordinator as coordinator_module
|
|
|
|
shutdown_calls = []
|
|
|
|
async def _record_shutdown(self):
|
|
shutdown_calls.append(self.entry_id)
|
|
|
|
monkeypatch.setattr(
|
|
coordinator_module.NestQuestCoordinator,
|
|
"async_shutdown",
|
|
_record_shutdown,
|
|
)
|
|
|
|
async def _boom(entry, platforms):
|
|
raise RuntimeError("platform forward failed")
|
|
|
|
monkeypatch.setattr(
|
|
hass.config_entries,
|
|
"async_forward_entry_setups",
|
|
_boom,
|
|
)
|
|
|
|
entry = wire_entry_to_registry(make_entry(), hass.registry)
|
|
with pytest.raises(RuntimeError, match="platform forward failed"):
|
|
await async_setup_entry(hass, entry)
|
|
assert shutdown_calls == [entry.entry_id]
|
|
assert getattr(entry, "runtime_data", None) is None
|
|
# The stale hass.data registration was removed with the runtime
|
|
# record: the unload below finds nothing stale, must not raise,
|
|
# and must leave no residue behind.
|
|
assert await async_unload_entry(hass, entry) is True
|
|
assert DOMAIN not in hass.data
|
|
assert hass.data == {}
|
|
assert hass.registry.size == 0
|
|
assert hass.time_change.size == 0
|
|
assert entry.runtime_data is None
|
|
|
|
|
|
async def test_snapshot_from_api_payload_empty_household() -> None:
|
|
"""An empty household (no children) is a valid snapshot: empty
|
|
children tuple, cycle day passes through."""
|
|
snapshot = snapshot_from_api_payload(
|
|
{
|
|
"today_iso": "2026-09-23",
|
|
"cycle_day": 0,
|
|
"timezone": "",
|
|
"children": [],
|
|
}
|
|
)
|
|
assert snapshot.children == ()
|
|
assert snapshot.cycle_day == 0
|
|
assert snapshot.today_iso == "2026-09-23"
|
|
|
|
|
|
#: The panel snapshot exactly as an API from before the ``timezone`` field
|
|
#: serves it: an integration upgraded ahead of its API still reads this.
|
|
LEGACY_SNAPSHOT = {
|
|
key: value for key, value in FIXTURE_SNAPSHOT.items() if key != "timezone"
|
|
}
|
|
|
|
|
|
async def test_coordinator_reads_a_legacy_payload_without_timezone(
|
|
hass, make_entry
|
|
) -> None:
|
|
"""An older API omits ``timezone``: every refresh still builds the
|
|
snapshot (the cards then fall back to Home Assistant's zone)."""
|
|
assert "timezone" not in LEGACY_SNAPSHOT
|
|
client = StubSnapshotClient(LEGACY_SNAPSHOT)
|
|
_entry, coordinator = await _wire_entry(hass, make_entry, client)
|
|
assert coordinator.last_update_success is True
|
|
|
|
snapshot = await coordinator._async_update_data()
|
|
assert snapshot.timezone == ""
|
|
assert snapshot.cycle_day == 3
|
|
assert [child.child_name for child in snapshot.children] == ["Ada", "Bo"]
|
|
assert snapshot_from_api_payload(LEGACY_SNAPSHOT).timezone == ""
|