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

440 lines
15 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
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
ECOWITT_PATH = ECOWITT_URL_PREFIX + "/{webhook_id}"
@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
async def test_register_path_raises_config_entry_not_ready_on_router_runtime_error(
hass_with_http,
):
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 = RuntimeError("router broken")
with pytest.raises(ConfigEntryNotReady):
register_path(hass_with_http, coordinator, coordinator_health, entry)
@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 -> PlatformNotReady."""
from homeassistant.exceptions import PlatformNotReady
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(PlatformNotReady):
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_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_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)
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)
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_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