Files

520 lines
20 KiB
Python

"""Tests for the setup/unload lifecycle of NestQuest."""
from __future__ import annotations
import asyncio
import pytest
from conftest import make_config_entry, make_hass, wire_entry_to_registry
from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry
from custom_components.nestquest.const import (
DOMAIN as DOMAIN_CONST,
SERVICE_COMPLETE_QUEST,
)
from custom_components.nestquest.dashboard import (
DASHBOARD_STRATEGY_TYPE,
DASHBOARD_URL_PATH,
async_register_dashboard,
)
def _wire(entry, registry):
return wire_entry_to_registry(entry, registry)
class FakeDashboardStore:
"""Stand-in for a per-dashboard CONTENT manager (LovelaceStorage).
In the real API the dashboard CONFIG (the strategy reference) is
saved through THIS manager's ``async_save``, NOT through the
metadata collection — so every strategy-save assertion reads here.
"""
def __init__(self) -> None:
self.saved: list[dict] = []
self.config: dict | None = None
async def async_save(self, config: dict) -> None:
self.saved.append(dict(config))
self.config = dict(config)
class FakeDashboardsCollection:
"""Stand-in mirroring the REAL Lovelace dashboards API shapes.
Faithful to ``homeassistant/components/lovelace`` +
``helpers/collection.py``: ``async_items()`` is a SYNC ``@callback``
(ObservableCollection.async_items returns list(data.values())),
``async_create_item`` IS async, and — mirroring lovelace's
``storage_dashboard_changed`` listener — a successful create
registers a fresh CONTENT manager under
``hass.data["lovelace"]["dashboards"][url_path]``, which is where
the dashboard config is then saved. Records ``async_create_item``
calls so tests can assert exactly what the setup path registered —
and that a reload registers nothing more.
"""
def __init__(self, hass, items=None) -> None:
self.hass = hass
self.items = list(items or [])
self.created: list[dict] = []
#: url_path -> content manager, mirroring the real listener's
# registrations (pre-existing items included).
self.stores: dict[str, FakeDashboardStore] = {}
def async_items(self):
"""SYNC, like the real ``ObservableCollection.async_items``."""
return list(self.items)
async def async_create_item(self, item_config: dict) -> None:
# The real collection awaits validation and change notification
# around the item insertion; yield first so two concurrent
# callers without a single-flight guard would BOTH get here.
await asyncio.sleep(0)
self.created.append(dict(item_config))
self.items.append(dict(item_config))
self.stores[item_config["url_path"]] = FakeDashboardStore()
self.hass.data["lovelace"]["dashboards"][item_config["url_path"]] = (
self.stores[item_config["url_path"]]
)
def _install_dashboard_collection(
hass, items=None
) -> FakeDashboardsCollection:
"""Install the REAL-API-shaped lovelace surfaces on the hass stub.
Mirrors what lovelace's ``async_setup`` leaves behind in storage
mode: ``hass.data["lovelace"]`` is a DICT with the metadata
collection under ``dashboards_collection`` and the per-url_path
CONTENT managers under ``dashboards``. Any pre-existing item gets
a content manager, exactly as lovelace's change listener would
have registered one when the dashboard was created.
"""
collection = FakeDashboardsCollection(hass, items)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
for item in collection.items:
url_path = item.get("url_path")
store = FakeDashboardStore()
collection.stores[url_path] = store
hass.data["lovelace"]["dashboards"][url_path] = store
return collection
async def test_setup_stores_runtime_data_in_hass_data(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert entry.entry_id in hass.data[DOMAIN]
assert hass.data[DOMAIN][entry.entry_id] is entry.runtime_data
async def test_setup_runtime_data_has_entry_id_and_coordinator(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
runtime = entry.runtime_data
assert runtime.entry_id == entry.entry_id
assert runtime.coordinator is not None
assert callable(runtime.remove_update_listener)
async def test_setup_registers_no_time_change_listener(hass, make_entry) -> None:
"""The integration owns no database any more: the daily
day-rollover/materialization listener is gone (the API service
owns the horizon), so setup registers NOTHING against the
time-change listener bookkeeping."""
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
assert hass.time_change.size == 0
async def test_unload_returns_true_and_removes_hass_data(hass, make_entry) -> None:
registry = hass.registry
entry = _wire(make_entry(), registry)
assert await async_setup_entry(hass, entry) is True
assert registry.size == 1
assert await async_unload_entry(hass, entry) is True
assert hass.data == {}
assert DOMAIN not in hass.data
assert entry.runtime_data is None
assert registry.size == 0
async def test_unload_of_one_entry_keeps_domain_while_other_remains(
hass, make_entry
) -> None:
"""Unloading one of two entries keeps DOMAIN alive with the other entry."""
registry = hass.registry
entry_a = _wire(make_entry(entry_id="entry_a"), registry)
entry_b = _wire(make_entry(entry_id="entry_b"), registry)
assert await async_setup_entry(hass, entry_a) is True
assert await async_setup_entry(hass, entry_b) is True
assert set(hass.data[DOMAIN]) == {"entry_a", "entry_b"}
assert await async_unload_entry(hass, entry_a) is True
assert DOMAIN in hass.data
assert set(hass.data[DOMAIN]) == {"entry_b"}
assert hass.data[DOMAIN]["entry_b"] is entry_b.runtime_data
assert entry_a.runtime_data is None
assert entry_b.runtime_data is not None
assert registry.size == 1
assert await async_unload_entry(hass, entry_b) is True
assert DOMAIN not in hass.data
assert hass.data == {}
assert entry_b.runtime_data is None
assert registry.size == 0
async def test_unload_is_idempotent(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert await async_unload_entry(hass, entry) is True
assert await async_unload_entry(hass, entry) is True
assert DOMAIN not in hass.data
assert hass.data == {}
async def test_unload_without_setup_returns_true(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
assert await async_unload_entry(hass, entry) is True
assert DOMAIN not in hass.data
assert hass.data == {}
async def test_unload_clears_entry_runtime_data(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
await async_unload_entry(hass, entry)
assert entry.runtime_data is None
async def test_unload_removes_update_listener(hass, make_entry) -> None:
registry = hass.registry
entry = _wire(make_entry(), registry)
await async_setup_entry(hass, entry)
assert registry.size == 1
await async_unload_entry(hass, entry)
assert registry.size == 0
async def test_options_update_triggers_reload_once(hass, make_entry) -> None:
registry = hass.registry
entry = _wire(make_entry(), registry)
await async_setup_entry(hass, entry)
entry.options = {"update_interval": 60}
await registry.async_dispatch_options_update(entry)
assert hass.config_entries.async_reload.call_count == 1
assert registry.reloaded == [entry.entry_id]
async def test_no_reload_after_unload(hass, make_entry) -> None:
registry = hass.registry
entry = _wire(make_entry(), registry)
await async_setup_entry(hass, entry)
await async_unload_entry(hass, entry)
await registry.async_dispatch_options_update(entry)
assert hass.config_entries.async_reload.call_count == 0
async def test_three_reload_cycles_keep_single_listener(hass, make_entry) -> None:
registry = hass.registry
entry = _wire(make_entry(), registry)
for _ in range(3):
assert await async_setup_entry(hass, entry) is True
assert registry.size == 1
assert await async_unload_entry(hass, entry) is True
assert registry.size == 0
assert hass.config_entries.async_reload.call_count == 0
async def test_three_setup_unload_cycles_leave_no_listeners(hass, make_entry) -> None:
"""Listeners accumulate per entry (HA semantics); unload must remove them all."""
registry = hass.registry
entry = _wire(make_entry(), registry)
for _ in range(3):
await async_setup_entry(hass, entry)
await async_unload_entry(hass, entry)
assert registry._listeners.get(entry.entry_id, []) == []
assert registry.size == 0
async def test_options_update_after_each_setup_invokes_exactly_one_listener(
hass, make_entry
) -> None:
"""After each setup, an options update dispatches to exactly one listener."""
registry = hass.registry
entry = _wire(make_entry(), registry)
for _ in range(3):
await async_setup_entry(hass, entry)
await registry.async_dispatch_options_update(entry)
assert hass.config_entries.async_reload.call_count == 1
assert registry.reloaded == [entry.entry_id]
await async_unload_entry(hass, entry)
hass.config_entries.async_reload.reset_mock()
registry.reloaded.clear()
async def test_reload_setup_cycles_dispatch_reload_each_time(hass, make_entry) -> None:
registry = hass.registry
entry = _wire(make_entry(), registry)
for _ in range(3):
await async_setup_entry(hass, entry)
await registry.async_dispatch_options_update(entry)
await async_unload_entry(hass, entry)
assert hass.config_entries.async_reload.call_count == 3
assert registry.size == 0
async def test_no_orphaned_tasks_after_unload(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
await async_unload_entry(hass, entry)
pending = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()]
assert pending == []
async def test_setup_entry_uses_domain_constant(hass, make_entry) -> None:
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
assert set(hass.data) == {DOMAIN_CONST}
async def test_setup_registers_the_completion_service(hass, make_entry) -> None:
"""The one domain-global service registers with setup and deregisters
on the last unload."""
entry = _wire(make_entry(), hass.registry)
await async_setup_entry(hass, entry)
assert hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
await async_unload_entry(hass, entry)
assert not hass.services.has_service(DOMAIN, SERVICE_COMPLETE_QUEST)
async def test_setup_awaitable_listener_removal_supported() -> None:
"""A coroutine-returning remover is awaited during unload."""
removed = []
async def _remover():
removed.append(True)
entry = make_config_entry(entry_id="awaitable")
entry.add_update_listener = lambda listener: _remover
hass, _registry = make_hass()
await async_setup_entry(hass, entry)
result = await async_unload_entry(hass, entry)
assert result is True
assert removed == [True]
assert hass.data == {}
assert entry.runtime_data is None
async def test_setup_unwinds_when_listener_registration_fails() -> None:
"""A listener-registration failure must not leak partial setup state."""
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
class _Boom(Exception):
pass
entry = make_config_entry(entry_id="listener-boom")
entry.add_update_listener = lambda listener: (_ for _ in ()).throw(_Boom())
hass, _registry = make_hass()
with pytest.raises(_Boom):
await async_setup_entry(hass, entry)
assert hass.data.get(DOMAIN_CONST, {}) == {}
assert not hasattr(entry, "runtime_data")
assert not hass.services.has_service(DOMAIN_CONST, SERVICE_COMPLETE_QUEST)
async def test_unload_shuts_coordinator_even_when_listener_removal_raises() -> None:
"""A raising update-listener remover must not skip the coordinator
shutdown: the retained error is re-raised only after the teardown
ran and the runtime record was cleared."""
from custom_components.nestquest.const import DOMAIN as DOMAIN_CONST
class _RemoveBoom(Exception):
pass
def _remover():
raise _RemoveBoom()
entry = make_config_entry(entry_id="remove-boom")
entry.add_update_listener = lambda listener: _remover
hass, _registry = make_hass()
await async_setup_entry(hass, entry)
coordinator = entry.runtime_data.coordinator
seen = []
async def _shutdown():
seen.append("shutdown")
coordinator.async_shutdown = _shutdown
with pytest.raises(_RemoveBoom):
await async_unload_entry(hass, entry)
assert seen == ["shutdown"], "coordinator shutdown must have run"
assert entry.runtime_data is None
assert DOMAIN_CONST not in hass.data
assert hass.time_change.size == 0
async def test_setup_creates_the_panel_dashboard(hass, make_entry) -> None:
"""A fresh install gets the storage-mode strategy dashboard via the
REAL two-step Lovelace sequence: the METADATA (url_path, title,
icon, sidebar, single-word allowance — nothing else: the real
create schema rejects unknown keys like ``strategy``) through the
collection, then the dashboard CONFIG (the strategy reference)
through the fresh dashboard's content manager."""
collection = _install_dashboard_collection(hass)
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
# Step 1: the metadata create through the collection.
assert len(collection.created) == 1
created = collection.created[0]
assert created["url_path"] == DASHBOARD_URL_PATH
assert created["title"]
assert created["show_in_sidebar"] is True
assert created["allow_single_word"] is True
assert "icon" in created
# The create payload carries METADATA only: a ``strategy`` key is
# rejected by the real collection's create schema (the silent
# no-op the first implementation shipped).
assert "strategy" not in created
assert "mode" not in created
# Step 2: the strategy CONFIG saved through the content manager
# the create registered, with the custom strategy type.
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert store is collection.stores[DASHBOARD_URL_PATH]
assert store.saved == [{"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}]
assert store.config == {"strategy": {"type": DASHBOARD_STRATEGY_TYPE}}
async def test_setup_reload_does_not_duplicate_the_dashboard(
hass, make_entry
) -> None:
"""A reload (or a second entry) must not create a second dashboard:
the one under the stable url_path is reused as-is — BOTH steps are
skipped, so the strategy config is not re-saved either."""
collection = _install_dashboard_collection(hass)
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert len(store.saved) == 1
# A reload: setup again on the same hass (the reload cycle) —
# the existing dashboard must be left alone.
await async_unload_entry(hass, entry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
assert len(store.saved) == 1
# And a second entry on the same install.
other = _wire(make_entry(entry_id="other"), hass.registry)
assert await async_setup_entry(hass, other) is True
assert len(collection.created) == 1
assert len(store.saved) == 1
async def test_setup_leaves_an_existing_user_dashboard_alone(
hass, make_entry
) -> None:
"""A dashboard already occupying the stable url_path — the user's
own — is never overwritten, deleted or duplicated: no metadata
create AND no strategy save against its content manager."""
users_dashboard = {"url_path": DASHBOARD_URL_PATH, "title": "My Board"}
collection = _install_dashboard_collection(hass, items=[users_dashboard])
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert collection.created == []
assert collection.items[0] is users_dashboard
users_store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert users_store.saved == []
assert users_store.config is None
async def test_setup_succeeds_without_the_dashboards_collection(
hass, make_entry, caplog
) -> None:
"""YAML mode / lovelace not loaded: log a warning, continue setup."""
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert entry.entry_id in hass.data[DOMAIN]
assert any(
"dashboard" in record.message.lower()
and record.levelname == "WARNING"
for record in caplog.records
)
async def test_setup_survives_a_failing_dashboards_collection(
hass, make_entry
) -> None:
"""A collection that refuses the create (permissions, admin-only)
logs a warning and setup still succeeds — entities come up."""
class _RefusingCollection(FakeDashboardsCollection):
async def async_create_item(self, item_config):
raise PermissionError("not an admin")
collection = _RefusingCollection(hass)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert collection.created == []
async def test_setup_survives_a_failing_content_manager_save(
hass, make_entry
) -> None:
"""A content manager that refuses the config save after the
metadata create succeeded (storage write failure) logs a warning
and setup still succeeds — the dashboard exists, the strategy
simply was not persisted through it."""
class _RefusingStore(FakeDashboardStore):
async def async_save(self, config):
raise OSError("disk full")
class _StorelessCreateCollection(FakeDashboardsCollection):
async def async_create_item(self, item_config):
await asyncio.sleep(0)
self.created.append(dict(item_config))
self.items.append(dict(item_config))
self.stores[item_config["url_path"]] = _RefusingStore()
self.hass.data["lovelace"]["dashboards"][
item_config["url_path"]
] = self.stores[item_config["url_path"]]
collection = _StorelessCreateCollection(hass)
hass.data["lovelace"] = {
"mode": "storage",
"dashboards": {},
"dashboards_collection": collection,
}
entry = _wire(make_entry(), hass.registry)
assert await async_setup_entry(hass, entry) is True
assert len(collection.created) == 1
store = hass.data["lovelace"]["dashboards"][DASHBOARD_URL_PATH]
assert store.saved == []
async def test_concurrent_registrations_create_the_dashboard_once(hass) -> None:
"""Two CONCURRENT registrations (the race two entry setups run)
must produce exactly ONE dashboard: the single-flight guard makes
the second caller's exists-check run after the first caller's
create completed, so it skips both steps. The fake's create
yields before recording, so without the guard both callers would
pass the exists-check and the dashboard would be created twice."""
collection = _install_dashboard_collection(hass)
results = await asyncio.gather(
async_register_dashboard(hass),
async_register_dashboard(hass),
)
assert results == [True, True]
assert len(collection.created) == 1
assert len(collection.stores[DASHBOARD_URL_PATH].saved) == 1