SWS-12500-custom-component/tests/test_integration_lifecycle.py

641 lines
23 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
import json
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from aiohttp.web_exceptions import HTTPUnauthorized
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.sws12500 import async_setup_entry, async_unload_entry, register_path, update_listener
from custom_components.sws12500.const import (
API_ID,
API_KEY,
DEFAULT_URL,
DOMAIN,
ECOWITT_URL_PREFIX,
HEALTH_URL,
SENSORS_TO_LOAD,
WSLINK,
WSLINK_URL,
)
from custom_components.sws12500.coordinator import IncorrectDataError, WeatherDataUpdateCoordinator
from custom_components.sws12500.data import SWSRuntimeData
from custom_components.sws12500.health_coordinator import HealthCoordinator
from custom_components.sws12500.routes import unregistered
ECOWITT_PATH = ECOWITT_URL_PREFIX + "/{webhook_id}"
COMPONENT_DIR = Path(__file__).resolve().parents[1] / "custom_components" / "sws12500"
@dataclass(slots=True)
class _RequestStub:
"""Minimal aiohttp Request stub used by `received_data`."""
query: dict[str, Any]
async def post(self) -> dict[str, Any]:
return {}
class _RouterStub:
"""Router stub that records route registrations."""
def __init__(self) -> None:
self.add_get_calls: list[tuple[str, Any]] = []
self.add_post_calls: list[tuple[str, Any]] = []
self.raise_on_add: Exception | None = None
def add_get(self, path: str, handler: Any, **_kwargs: Any) -> Any:
if self.raise_on_add is not None:
raise self.raise_on_add
self.add_get_calls.append((path, handler))
return SimpleNamespace(method="GET")
def add_post(self, path: str, handler: Any, **_kwargs: Any) -> Any:
if self.raise_on_add is not None:
raise self.raise_on_add
self.add_post_calls.append((path, handler))
return SimpleNamespace(method="POST")
@pytest.fixture
def hass_with_http(hass):
"""Provide a real HA hass fixture augmented with a stub http router."""
router = _RouterStub()
hass.http = SimpleNamespace(app=SimpleNamespace(router=router))
return hass
def _mock_health_first_refresh(monkeypatch) -> None:
"""Calling async_setup_entry directly leaves the entry NOT_LOADED.
The health coordinator's first refresh requires SETUP_IN_PROGRESS and does network
I/O, so we mock it out to keep these lifecycle tests focused on wiring.
"""
monkeypatch.setattr(
"custom_components.sws12500.HealthCoordinator.async_config_entry_first_refresh",
AsyncMock(return_value=None),
)
# --- register_path ---------------------------------------------------------
@pytest.mark.asyncio
async def test_register_path_registers_routes_and_stores_dispatcher(hass_with_http):
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
ok = register_path(hass_with_http, coordinator, coordinator_health, entry)
assert ok is True
# Router registrations: GET for legacy/wslink/health, POST for wslink + ecowitt.
router: _RouterStub = hass_with_http.http.app.router
assert [p for (p, _h) in router.add_get_calls] == [
DEFAULT_URL,
WSLINK_URL,
HEALTH_URL,
]
assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL, ECOWITT_PATH]
# Dispatcher stored under the shared (cross-reload) hass.data[DOMAIN].
assert DOMAIN in hass_with_http.data
routes = hass_with_http.data[DOMAIN].get("routes")
assert routes is not None
assert isinstance(routes.show_enabled(), str)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("router_error", "expected_key"),
[
(RuntimeError("router broken"), "webhook_routes_failed"),
# aiohttp's own wording when a route *name* is taken. The names here are fixed
# rather than domain-scoped, so a previous version of this integration that is
# still installed holds them - the state a half-finished migration leaves behind.
(
ValueError("Duplicate '_default_route', already handled by <ResourceRoute>"),
"webhook_routes_taken",
),
# aiohttp raises ValueError for a malformed path too. Same exception type, and
# nothing whatsoever to do with a leftover previous version.
(ValueError("Path should be started with / got 'weatherhub'"), "webhook_routes_failed"),
],
ids=["runtime_error", "duplicate_route_name", "malformed_path"],
)
async def test_register_path_raises_config_entry_not_ready_on_router_error(
hass_with_http,
router_error: Exception,
expected_key: str,
):
"""Only a duplicate route name may be reported as a leftover previous version.
The message is the only thing the user sees on the integrations page. Telling
somebody with a malformed path to uninstall a version they may never have installed
sends them off the trail, while the real cause sits in the log line above.
"""
from homeassistant.exceptions import ConfigEntryNotReady
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
router: _RouterStub = hass_with_http.http.app.router
router.raise_on_add = router_error
with pytest.raises(ConfigEntryNotReady) as excinfo:
register_path(hass_with_http, coordinator, coordinator_health, entry)
assert excinfo.value.translation_key == expected_key
assert excinfo.value.translation_domain == DOMAIN
# The original exception is what the log line carries, in both cases.
assert excinfo.value.__cause__ is router_error
messages = json.loads((COMPONENT_DIR / "strings.json").read_text(encoding="utf-8"))["exceptions"]
shown = messages[expected_key]["message"]
if expected_key == "webhook_routes_taken":
assert "HACS" in shown
else:
assert "HACS" not in shown, "a generic router failure must not tell the user to uninstall anything"
@pytest.mark.asyncio
async def test_route_failure_messages_are_translated(hass, enable_custom_integrations):
"""A translation key that resolves to nothing shows the user the raw key.
Home Assistant looks exception messages up under `component.<domain>.exceptions.
<key>.message`, loading them from the integration's own `translations/` directory -
custom integrations included. This asserts the wiring, so a renamed or missing key
fails here rather than on somebody's integrations page.
"""
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import translation
hass.config.components.add(DOMAIN)
await translation.async_load_integrations(hass, {DOMAIN})
taken = ConfigEntryNotReady(translation_domain=DOMAIN, translation_key="webhook_routes_taken")
assert "HACS" in str(taken)
failed = ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="webhook_routes_failed",
translation_placeholders={"error": "router broken"},
)
assert "router broken" in str(failed)
assert "HACS" not in str(failed)
@pytest.mark.asyncio
async def test_register_path_checked_hass_data_wrong_type_raises_config_entry_not_ready(
hass_with_http,
):
"""Cover register_path branch where `checked(hass.data[DOMAIN], dict)` returns None."""
from homeassistant.exceptions import ConfigEntryNotReady
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
hass_with_http.data[DOMAIN] = [] # wrong type -> checked(..., dict) fails
with pytest.raises(ConfigEntryNotReady):
register_path(hass_with_http, coordinator, coordinator_health, entry)
# --- async_setup_entry -----------------------------------------------------
@pytest.mark.asyncio
async def test_async_setup_entry_creates_runtime_data_and_forwards_platforms(
hass_with_http,
monkeypatch,
):
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
_mock_health_first_refresh(monkeypatch)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
ok = await async_setup_entry(hass_with_http, entry)
assert ok is True
# Per-entry state now lives on entry.runtime_data (SWSRuntimeData).
assert isinstance(entry.runtime_data, SWSRuntimeData)
assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator)
assert isinstance(entry.runtime_data.last_options, dict)
# Shared dispatcher registered under hass.data[DOMAIN].
assert "routes" in hass_with_http.data[DOMAIN]
hass_with_http.config_entries.async_forward_entry_setups.assert_awaited()
@pytest.mark.asyncio
async def test_async_setup_entry_fatal_when_register_path_returns_false(
hass_with_http, monkeypatch
):
"""Cover the fatal branch when `register_path` returns False -> ConfigEntryNotReady."""
from homeassistant.exceptions import ConfigEntryNotReady
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
# No pre-registered routes -> async_setup_entry calls register_path.
hass_with_http.data.setdefault(DOMAIN, {})
hass_with_http.data[DOMAIN].pop("routes", None)
monkeypatch.setattr(
"custom_components.sws12500.register_path",
lambda _hass, _coordinator, _coordinator_h, _entry: False,
)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
with pytest.raises(ConfigEntryNotReady):
await async_setup_entry(hass_with_http, entry)
@pytest.mark.asyncio
async def test_async_setup_entry_reuses_route_dispatcher_and_switches_protocol(
hass_with_http,
monkeypatch,
):
"""On reload the shared route dispatcher is reused; the coordinator is recreated."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
# Pre-register routes once (legacy/WU active).
initial_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
initial_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, initial_coordinator, initial_health, entry)
routes_before = hass_with_http.data[DOMAIN]["routes"]
assert routes_before.path_enabled(DEFAULT_URL) is True
# Switch to WSLink and run setup again.
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), WSLINK: True}
)
_mock_health_first_refresh(monkeypatch)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
ok = await async_setup_entry(hass_with_http, entry)
assert ok is True
# Same dispatcher object reused (survives across reloads).
assert hass_with_http.data[DOMAIN]["routes"] is routes_before
# Protocol switched to WSLink.
assert routes_before.path_enabled(WSLINK_URL) is True
assert routes_before.path_enabled(DEFAULT_URL) is False
# A fresh coordinator is wired onto entry.runtime_data.
assert isinstance(entry.runtime_data, SWSRuntimeData)
assert isinstance(entry.runtime_data.coordinator, WeatherDataUpdateCoordinator)
# --- update_listener -------------------------------------------------------
def _entry_with_runtime(hass, *, options: dict[str, Any]) -> MockConfigEntry:
entry = MockConfigEntry(domain=DOMAIN, data={}, options=options)
entry.add_to_hass(hass)
entry.runtime_data = SWSRuntimeData(
coordinator=object(), # type: ignore[arg-type]
health_coordinator=object(), # type: ignore[arg-type]
last_options=dict(options),
)
return entry
@pytest.mark.asyncio
async def test_update_listener_skips_reload_when_only_sensors_to_load_changes(
hass_with_http,
):
entry = _entry_with_runtime(
hass_with_http,
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"]},
)
hass_with_http.config_entries.async_reload = AsyncMock()
# Only SENSORS_TO_LOAD changes.
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), SENSORS_TO_LOAD: ["a", "b"]}
)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_not_awaited()
# The snapshot on runtime_data is refreshed.
assert entry.runtime_data.last_options == dict(entry.options)
@pytest.mark.asyncio
async def test_update_listener_skips_reload_when_only_probe_types_change(
hass_with_http,
):
"""The webhook handler writes these on every new probe type it is told about.
Reloading a push-based integration there would drop coordinator listeners.
"""
from custom_components.sws12500.const import CHANNEL_TYPES
entry = _entry_with_runtime(
hass_with_http,
options={API_ID: "id", API_KEY: "key", CHANNEL_TYPES: {"t234c1tp": "2"}},
)
hass_with_http.config_entries.async_reload = AsyncMock()
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), CHANNEL_TYPES: {"t234c1tp": "4"}}
)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_not_awaited()
assert entry.runtime_data.last_options == dict(entry.options)
@pytest.mark.asyncio
async def test_update_listener_triggers_reload_when_other_option_changes(
hass_with_http,
monkeypatch,
):
entry = _entry_with_runtime(
hass_with_http,
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
)
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
hass_with_http.config_entries.async_update_entry(
entry, options={**dict(entry.options), WSLINK: True}
)
info = MagicMock()
monkeypatch.setattr("custom_components.sws12500._LOGGER.info", info)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
info.assert_called()
@pytest.mark.asyncio
async def test_update_listener_without_runtime_snapshot_reloads(hass_with_http):
"""When runtime_data is not a valid snapshot, update_listener reloads."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"], WSLINK: False},
)
entry.add_to_hass(hass_with_http)
# Not an SWSRuntimeData instance -> the skip-reload fast path is bypassed.
entry.runtime_data = "invalid"
hass_with_http.config_entries.async_reload = AsyncMock(return_value=True)
await update_listener(hass_with_http, entry)
hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id)
# --- async_unload_entry ----------------------------------------------------
@pytest.mark.asyncio
async def test_async_unload_entry_returns_true_on_success(hass_with_http):
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=True)
ok = await async_unload_entry(hass_with_http, entry)
assert ok is True
hass_with_http.config_entries.async_unload_platforms.assert_awaited_once()
@pytest.mark.asyncio
async def test_async_unload_entry_deactivates_shared_routes(hass_with_http):
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, coordinator, coordinator_health, entry)
entry.runtime_data = SWSRuntimeData(
coordinator=coordinator,
health_coordinator=coordinator_health,
last_options=dict(entry.options),
)
routes = hass_with_http.data[DOMAIN]["routes"]
assert routes.path_enabled(DEFAULT_URL) is True
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=True)
ok = await async_unload_entry(hass_with_http, entry)
assert ok is True
assert routes.path_enabled(DEFAULT_URL) is False
assert getattr(entry, "runtime_data", None) is None
response = await routes.dispatch(SimpleNamespace(method="GET", path=DEFAULT_URL))
assert response.status == 503
@pytest.mark.asyncio
async def test_async_remove_entry_releases_dispatcher_references(hass_with_http):
"""Unload keeps the observer on purpose; removal has to let it go.
The dispatcher outlives the entry in `hass.data`, so a removed entry whose
coordinators are still referenced there stays alive for the rest of the process.
"""
from custom_components.sws12500 import async_remove_entry
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, coordinator, coordinator_health, entry)
routes = hass_with_http.data[DOMAIN]["routes"]
routes.deactivate()
# Unload deliberately keeps the observer so mid-reload ingress is still recorded.
assert routes._ingress_observer is not None
await async_remove_entry(hass_with_http, entry)
assert routes._ingress_observer is None
assert all(route.handler is unregistered for route in routes.routes.values())
@pytest.mark.asyncio
async def test_reload_after_unload_still_rebinds_the_observer(hass_with_http, monkeypatch):
"""Release must not be reached on the reload path."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
register_path(hass_with_http, coordinator, coordinator_health, entry)
routes = hass_with_http.data[DOMAIN]["routes"]
first_observer = routes._ingress_observer
_mock_health_first_refresh(monkeypatch)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_unload_platforms",
AsyncMock(return_value=True),
)
monkeypatch.setattr(
hass_with_http.config_entries,
"async_forward_entry_setups",
AsyncMock(return_value=True),
)
await async_unload_entry(hass_with_http, entry)
assert await async_setup_entry(hass_with_http, entry) is True
assert routes.active is True
assert routes._ingress_observer is not None
assert routes._ingress_observer != first_observer
assert routes.path_enabled(HEALTH_URL) is True
@pytest.mark.asyncio
async def test_async_unload_entry_returns_false_on_failure(hass_with_http):
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key"})
entry.add_to_hass(hass_with_http)
hass_with_http.config_entries.async_unload_platforms = AsyncMock(return_value=False)
ok = await async_unload_entry(hass_with_http, entry)
assert ok is False
# --- coordinator auth (lifecycle-adjacent) ---------------------------------
@pytest.mark.asyncio
async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass):
"""Cover coordinator auth behavior reachable from the webhook entrypoint."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass)
entry.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
coordinator = WeatherDataUpdateCoordinator(hass, entry)
# Missing security params -> unauthorized
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(_RequestStub(query={"x": "y"})) # type: ignore[arg-type]
# Wrong credentials -> unauthorized
with pytest.raises(HTTPUnauthorized):
await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "no"})
) # type: ignore[arg-type]
# Missing API_ID in options -> IncorrectDataError
entry2 = MockConfigEntry(domain=DOMAIN, data={}, options={API_KEY: "key", WSLINK: False})
entry2.add_to_hass(hass)
entry2.runtime_data = SWSRuntimeData(coordinator=object(), health_coordinator=None, last_options={}) # type: ignore[arg-type]
coordinator2 = WeatherDataUpdateCoordinator(hass, entry2)
with pytest.raises(IncorrectDataError):
await coordinator2.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
@pytest.mark.asyncio
async def test_received_data_returns_503_when_runtime_data_missing(hass):
"""A payload arriving while the entry is unloaded is rejected with 503, not 500."""
entry = MockConfigEntry(domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key", WSLINK: False})
entry.add_to_hass(hass)
# No runtime_data assigned -> simulates the unloaded/reloading window.
coordinator = WeatherDataUpdateCoordinator(hass, entry)
resp = await coordinator.received_data(
_RequestStub(query={"ID": "id", "PASSWORD": "key"})
) # type: ignore[arg-type]
assert resp.status == 503
@pytest.mark.asyncio
async def test_register_path_idempotent_when_routes_exist(hass_with_http):
"""A second register_path call reuses the existing dispatcher (no new aiohttp routes)."""
entry = MockConfigEntry(
domain=DOMAIN,
data={},
options={API_ID: "id", API_KEY: "key", WSLINK: False},
)
entry.add_to_hass(hass_with_http)
coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry)
coordinator_health = HealthCoordinator(hass_with_http, entry)
assert register_path(hass_with_http, coordinator, coordinator_health, entry) is True
router: _RouterStub = hass_with_http.http.app.router
get_calls_after_first = list(router.add_get_calls)
post_calls_after_first = list(router.add_post_calls)
# Routes already a Routes instance -> else branch; nothing re-registered on aiohttp.
assert register_path(hass_with_http, coordinator, coordinator_health, entry) is True
assert router.add_get_calls == get_calls_after_first
assert router.add_post_calls == post_calls_after_first