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 ( IncorrectDataError, WeatherDataUpdateCoordinator, async_setup_entry, async_unload_entry, register_path, update_listener, ) from custom_components.sws12500.const import ( API_ID, API_KEY, DEFAULT_URL, DOMAIN, SENSORS_TO_LOAD, WSLINK, WSLINK_URL, ) from custom_components.sws12500.data import ENTRY_COORDINATOR, ENTRY_LAST_OPTIONS @dataclass(slots=True) class _RequestStub: """Minimal aiohttp Request stub used by `received_data`.""" query: dict[str, Any] 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) -> Any: if self.raise_on_add is not None: raise self.raise_on_add self.add_get_calls.append((path, handler)) return object() def add_post(self, path: str, handler: Any) -> Any: if self.raise_on_add is not None: raise self.raise_on_add self.add_post_calls.append((path, handler)) return object() @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 @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) ok = register_path(hass_with_http, coordinator, entry) assert ok is True # Router registrations router: _RouterStub = hass_with_http.http.app.router assert [p for (p, _h) in router.add_get_calls] == [DEFAULT_URL] assert [p for (p, _h) in router.add_post_calls] == [WSLINK_URL] # Dispatcher stored assert DOMAIN in hass_with_http.data assert "routes" in hass_with_http.data[DOMAIN] routes = hass_with_http.data[DOMAIN]["routes"] assert routes is not None # show_enabled() should return a string 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) # Make router raise RuntimeError on add 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, 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) # Force wrong type under DOMAIN so `checked(..., dict)` fails. hass_with_http.data[DOMAIN] = [] with pytest.raises(ConfigEntryNotReady): register_path(hass_with_http, coordinator, entry) @pytest.mark.asyncio async def test_async_setup_entry_creates_entry_dict_and_coordinator_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) # Avoid loading actual platforms via HA loader. 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 # Runtime storage exists and is a dict assert DOMAIN in hass_with_http.data assert entry.entry_id in hass_with_http.data[DOMAIN] entry_data = hass_with_http.data[DOMAIN][entry.entry_id] assert isinstance(entry_data, dict) # Coordinator stored and last options snapshot stored assert isinstance(entry_data.get(ENTRY_COORDINATOR), WeatherDataUpdateCoordinator) assert isinstance(entry_data.get(ENTRY_LAST_OPTIONS), dict) # Forwarded setups invoked 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. async_setup_entry does: routes_enabled = register_path(...) if not routes_enabled: raise 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) # Ensure there are no pre-registered routes so async_setup_entry calls register_path. hass_with_http.data.setdefault(DOMAIN, {}) hass_with_http.data[DOMAIN].pop("routes", None) # Force register_path to return False monkeypatch.setattr( "custom_components.sws12500.register_path", lambda _hass, _coordinator, _entry: False, ) # Forwarding shouldn't be reached; patch anyway to avoid accidental loader calls. 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_existing_coordinator_and_switches_routes( 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) # Pretend setup already happened and a coordinator exists hass_with_http.data.setdefault(DOMAIN, {}) existing_coordinator = WeatherDataUpdateCoordinator(hass_with_http, entry) hass_with_http.data[DOMAIN][entry.entry_id] = { ENTRY_COORDINATOR: existing_coordinator, ENTRY_LAST_OPTIONS: dict(entry.options), } # Provide pre-registered routes dispatcher routes = hass_with_http.data[DOMAIN].get("routes") if routes is None: # Create a dispatcher via register_path once register_path(hass_with_http, existing_coordinator, entry) routes = hass_with_http.data[DOMAIN]["routes"] # Turn on WSLINK to trigger dispatcher switching. # ConfigEntry.options cannot be changed directly; use async_update_entry. hass_with_http.config_entries.async_update_entry( entry, options={**dict(entry.options), WSLINK: True} ) # Avoid loading actual platforms via HA loader. 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 # Coordinator reused (same object) entry_data = hass_with_http.data[DOMAIN][entry.entry_id] assert entry_data[ENTRY_COORDINATOR] is existing_coordinator @pytest.mark.asyncio async def test_update_listener_skips_reload_when_only_sensors_to_load_changes( hass_with_http, ): entry = MockConfigEntry( domain=DOMAIN, data={}, options={API_ID: "id", API_KEY: "key", SENSORS_TO_LOAD: ["a"]}, ) entry.add_to_hass(hass_with_http) # Seed hass.data snapshot hass_with_http.data.setdefault(DOMAIN, {}) hass_with_http.data[DOMAIN][entry.entry_id] = { # Seed the full old options snapshot. If we only store SENSORS_TO_LOAD here, # update_listener will detect differences for other keys (e.g. auth keys) and reload. ENTRY_LAST_OPTIONS: dict(entry.options), } hass_with_http.config_entries.async_reload = AsyncMock() # Only SENSORS_TO_LOAD changes. # ConfigEntry.options cannot be changed directly; use async_update_entry. 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() # Snapshot should be updated entry_data = hass_with_http.data[DOMAIN][entry.entry_id] assert entry_data[ENTRY_LAST_OPTIONS] == dict(entry.options) @pytest.mark.asyncio async def test_update_listener_triggers_reload_when_other_option_changes( hass_with_http, monkeypatch, ): 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) hass_with_http.data.setdefault(DOMAIN, {}) hass_with_http.data[DOMAIN][entry.entry_id] = { ENTRY_LAST_OPTIONS: dict(entry.options), } hass_with_http.config_entries.async_reload = AsyncMock(return_value=True) # Change a different option. # ConfigEntry.options cannot be changed directly; use async_update_entry. 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_missing_snapshot_stores_current_options_then_reloads( hass_with_http, ): """Cover update_listener branch where the options snapshot is missing/invalid. This hits: entry_data[ENTRY_LAST_OPTIONS] = dict(entry.options) and then proceeds to reload. """ 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) hass_with_http.data.setdefault(DOMAIN, {}) # Store an invalid snapshot type to force the "No/invalid snapshot" branch. hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_LAST_OPTIONS: "invalid"} hass_with_http.config_entries.async_reload = AsyncMock(return_value=True) await update_listener(hass_with_http, entry) entry_data = hass_with_http.data[DOMAIN][entry.entry_id] assert entry_data[ENTRY_LAST_OPTIONS] == dict(entry.options) hass_with_http.config_entries.async_reload.assert_awaited_once_with(entry.entry_id) @pytest.mark.asyncio async def test_async_unload_entry_pops_runtime_data_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.data.setdefault(DOMAIN, {}) hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_COORDINATOR: object()} 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 entry.entry_id not in hass_with_http.data[DOMAIN] @pytest.mark.asyncio async def test_async_unload_entry_keeps_runtime_data_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.data.setdefault(DOMAIN, {}) hass_with_http.data[DOMAIN][entry.entry_id] = {ENTRY_COORDINATOR: object()} 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 assert entry.entry_id in hass_with_http.data[DOMAIN] @pytest.mark.asyncio async def test_received_data_auth_unauthorized_and_incorrect_data_paths(hass): """A few lifecycle-adjacent assertions to cover coordinator auth behavior in __init__.py.""" 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]