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 custom_components.sws12500 import IncorrectDataError, WeatherDataUpdateCoordinator from custom_components.sws12500.const import ( API_ID, API_KEY, DEFAULT_URL, DOMAIN, POCASI_CZ_ENABLED, SENSORS_TO_LOAD, WINDY_ENABLED, WSLINK, WSLINK_URL, ) @dataclass(slots=True) class _RequestStub: """Minimal aiohttp Request stub. The coordinator only uses `webdata.query` (a mapping of query parameters). """ query: dict[str, Any] def _make_entry( *, wslink: bool = False, api_id: str | None = "id", api_key: str | None = "key", windy_enabled: bool = False, pocasi_enabled: bool = False, dev_debug: bool = False, ) -> Any: """Create a minimal config entry stub with `.options` and `.entry_id`.""" options: dict[str, Any] = { WSLINK: wslink, WINDY_ENABLED: windy_enabled, POCASI_CZ_ENABLED: pocasi_enabled, "dev_debug_checkbox": dev_debug, } if api_id is not None: options[API_ID] = api_id if api_key is not None: options[API_KEY] = api_key entry = SimpleNamespace() entry.entry_id = "test_entry_id" entry.options = options return entry @pytest.mark.asyncio async def test_received_data_wu_missing_security_params_raises_http_unauthorized( hass, monkeypatch ): entry = _make_entry(wslink=False) coordinator = WeatherDataUpdateCoordinator(hass, entry) # No ID/PASSWORD -> unauthorized request = _RequestStub(query={"foo": "bar"}) with pytest.raises(HTTPUnauthorized): await coordinator.received_data(request) # type: ignore[arg-type] @pytest.mark.asyncio async def test_received_data_wslink_missing_security_params_raises_http_unauthorized( hass, monkeypatch ): entry = _make_entry(wslink=True) coordinator = WeatherDataUpdateCoordinator(hass, entry) # No wsid/wspw -> unauthorized request = _RequestStub(query={"foo": "bar"}) with pytest.raises(HTTPUnauthorized): await coordinator.received_data(request) # type: ignore[arg-type] @pytest.mark.asyncio async def test_received_data_missing_api_id_in_options_raises_incorrect_data_error( hass, monkeypatch ): entry = _make_entry(wslink=False, api_id=None, api_key="key") coordinator = WeatherDataUpdateCoordinator(hass, entry) request = _RequestStub(query={"ID": "id", "PASSWORD": "key"}) with pytest.raises(IncorrectDataError): await coordinator.received_data(request) # type: ignore[arg-type] @pytest.mark.asyncio async def test_received_data_missing_api_key_in_options_raises_incorrect_data_error( hass, monkeypatch ): entry = _make_entry(wslink=False, api_id="id", api_key=None) coordinator = WeatherDataUpdateCoordinator(hass, entry) request = _RequestStub(query={"ID": "id", "PASSWORD": "key"}) with pytest.raises(IncorrectDataError): await coordinator.received_data(request) # type: ignore[arg-type] @pytest.mark.asyncio async def test_received_data_wrong_credentials_raises_http_unauthorized( hass, monkeypatch ): entry = _make_entry(wslink=False, api_id="id", api_key="key") coordinator = WeatherDataUpdateCoordinator(hass, entry) request = _RequestStub(query={"ID": "id", "PASSWORD": "wrong"}) with pytest.raises(HTTPUnauthorized): await coordinator.received_data(request) # type: ignore[arg-type] @pytest.mark.asyncio async def test_received_data_success_remaps_and_updates_coordinator_data( hass, monkeypatch ): entry = _make_entry(wslink=False, api_id="id", api_key="key") coordinator = WeatherDataUpdateCoordinator(hass, entry) # Patch remapping so this test doesn't depend on mapping tables. remapped = {"outside_temp": "10"} monkeypatch.setattr( "custom_components.sws12500.remap_items", lambda _data: remapped, ) # Ensure no autodiscovery triggers monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: [], ) # Capture updates coordinator.async_set_updated_data = MagicMock() request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "tempf": "50"}) resp = await coordinator.received_data(request) # type: ignore[arg-type] assert resp.status == 200 coordinator.async_set_updated_data.assert_called_once_with(remapped) @pytest.mark.asyncio async def test_received_data_success_wslink_uses_wslink_remap(hass, monkeypatch): entry = _make_entry(wslink=True, api_id="id", api_key="key") coordinator = WeatherDataUpdateCoordinator(hass, entry) remapped = {"ws_temp": "1"} monkeypatch.setattr( "custom_components.sws12500.remap_wslink_items", lambda _data: remapped, ) # If the wrong remapper is used, we'd crash because we won't patch it: monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: [], ) coordinator.async_set_updated_data = MagicMock() request = _RequestStub(query={"wsid": "id", "wspw": "key", "t": "1"}) resp = await coordinator.received_data(request) # type: ignore[arg-type] assert resp.status == 200 coordinator.async_set_updated_data.assert_called_once_with(remapped) @pytest.mark.asyncio async def test_received_data_forwards_to_windy_when_enabled(hass, monkeypatch): entry = _make_entry(wslink=False, api_id="id", api_key="key", windy_enabled=True) coordinator = WeatherDataUpdateCoordinator(hass, entry) coordinator.windy.push_data_to_windy = AsyncMock() monkeypatch.setattr( "custom_components.sws12500.remap_items", lambda _data: {"k": "v"}, ) monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: [], ) coordinator.async_set_updated_data = MagicMock() request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "x": "y"}) resp = await coordinator.received_data(request) # type: ignore[arg-type] assert resp.status == 200 coordinator.windy.push_data_to_windy.assert_awaited_once() args, _kwargs = coordinator.windy.push_data_to_windy.await_args assert isinstance(args[0], dict) # raw data dict assert args[1] is False # wslink flag @pytest.mark.asyncio async def test_received_data_forwards_to_pocasi_when_enabled(hass, monkeypatch): entry = _make_entry(wslink=True, api_id="id", api_key="key", pocasi_enabled=True) coordinator = WeatherDataUpdateCoordinator(hass, entry) coordinator.pocasi.push_data_to_server = AsyncMock() monkeypatch.setattr( "custom_components.sws12500.remap_wslink_items", lambda _data: {"k": "v"}, ) monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: [], ) coordinator.async_set_updated_data = MagicMock() request = _RequestStub(query={"wsid": "id", "wspw": "key", "x": "y"}) resp = await coordinator.received_data(request) # type: ignore[arg-type] assert resp.status == 200 coordinator.pocasi.push_data_to_server.assert_awaited_once() args, _kwargs = coordinator.pocasi.push_data_to_server.await_args assert isinstance(args[0], dict) # raw data dict assert args[1] == "WSLINK" @pytest.mark.asyncio async def test_received_data_autodiscovery_updates_options_notifies_and_adds_sensors( hass, monkeypatch, ): entry = _make_entry(wslink=False, api_id="id", api_key="key") coordinator = WeatherDataUpdateCoordinator(hass, entry) # Arrange: remapped payload contains keys that are disabled. remapped = {"a": "1", "b": "2"} monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped) # Autodiscovery finds two sensors to add monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: ["a", "b"], ) # No previously loaded sensors monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: []) # translations returns a friendly name for each sensor key async def _translations(_hass, _domain, _key, **_kwargs): # return something non-None so it's included in human readable string return "Name" monkeypatch.setattr("custom_components.sws12500.translations", _translations) translated_notification = AsyncMock() monkeypatch.setattr( "custom_components.sws12500.translated_notification", translated_notification ) update_options = AsyncMock() monkeypatch.setattr("custom_components.sws12500.update_options", update_options) add_new_sensors = MagicMock() monkeypatch.setattr( "custom_components.sws12500.sensor.add_new_sensors", add_new_sensors ) coordinator.async_set_updated_data = MagicMock() request = _RequestStub(query={"ID": "id", "PASSWORD": "key"}) resp = await coordinator.received_data(request) # type: ignore[arg-type] assert resp.status == 200 # It should notify translated_notification.assert_awaited() # It should persist newly discovered sensors update_options.assert_awaited() args, _kwargs = update_options.await_args assert args[2] == SENSORS_TO_LOAD assert set(args[3]) >= {"a", "b"} # It should add new sensors dynamically add_new_sensors.assert_called_once() _hass_arg, _entry_arg, keys = add_new_sensors.call_args.args assert _hass_arg is hass assert _entry_arg is entry assert set(keys) == {"a", "b"} coordinator.async_set_updated_data.assert_called_once_with(remapped) @pytest.mark.asyncio async def test_received_data_autodiscovery_human_readable_empty_branch_via_checked_none( hass, monkeypatch, ): """Force `checked([...], list[str])` to return None so `human_readable = ""` branch is executed.""" entry = _make_entry(wslink=False, api_id="id", api_key="key") coordinator = WeatherDataUpdateCoordinator(hass, entry) remapped = {"a": "1"} monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped) monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: ["a"], ) monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: []) # Return a translation so the list comprehension would normally include an item. async def _translations(_hass, _domain, _key, **_kwargs): return "Name" monkeypatch.setattr("custom_components.sws12500.translations", _translations) # Force checked(...) to return None when the code tries to validate translate_sensors as list[str]. def _checked_override(value, expected_type): if expected_type == list[str]: return None return value monkeypatch.setattr("custom_components.sws12500.checked", _checked_override) translated_notification = AsyncMock() monkeypatch.setattr( "custom_components.sws12500.translated_notification", translated_notification ) update_options = AsyncMock() monkeypatch.setattr("custom_components.sws12500.update_options", update_options) add_new_sensors = MagicMock() monkeypatch.setattr( "custom_components.sws12500.sensor.add_new_sensors", add_new_sensors ) coordinator.async_set_updated_data = MagicMock() request = _RequestStub(query={"ID": "id", "PASSWORD": "key"}) resp = await coordinator.received_data(request) # type: ignore[arg-type] assert resp.status == 200 # Ensure it still notifies (with empty human readable list) translated_notification.assert_awaited() # And persists sensors update_options.assert_awaited() coordinator.async_set_updated_data.assert_called_once_with(remapped) @pytest.mark.asyncio async def test_received_data_autodiscovery_extends_with_loaded_sensors_branch( hass, monkeypatch ): """Cover `_loaded_sensors := loaded_sensors(self.config)` branch (extend existing).""" entry = _make_entry(wslink=False, api_id="id", api_key="key") coordinator = WeatherDataUpdateCoordinator(hass, entry) remapped = {"new": "1"} monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped) # Autodiscovery finds one new sensor monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: ["new"], ) # Pretend there are already loaded sensors in options monkeypatch.setattr( "custom_components.sws12500.loaded_sensors", lambda _c: ["existing"] ) async def _translations(_hass, _domain, _key, **_kwargs): return "Name" monkeypatch.setattr("custom_components.sws12500.translations", _translations) monkeypatch.setattr( "custom_components.sws12500.translated_notification", AsyncMock() ) update_options = AsyncMock() monkeypatch.setattr("custom_components.sws12500.update_options", update_options) monkeypatch.setattr( "custom_components.sws12500.sensor.add_new_sensors", MagicMock() ) coordinator.async_set_updated_data = MagicMock() resp = await coordinator.received_data( _RequestStub(query={"ID": "id", "PASSWORD": "key"}) ) # type: ignore[arg-type] assert resp.status == 200 # Ensure the persisted list includes both new and existing sensors update_options.assert_awaited() args, _kwargs = update_options.await_args assert args[2] == SENSORS_TO_LOAD assert set(args[3]) >= {"new", "existing"} @pytest.mark.asyncio async def test_received_data_autodiscovery_translations_all_none_still_notifies_and_updates( hass, monkeypatch ): """Cover the branch where translated sensor names cannot be resolved (human_readable becomes empty).""" entry = _make_entry(wslink=False, api_id="id", api_key="key") coordinator = WeatherDataUpdateCoordinator(hass, entry) remapped = {"a": "1"} monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: remapped) monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: ["a"], ) monkeypatch.setattr("custom_components.sws12500.loaded_sensors", lambda _c: []) # Force translations to return None for every lookup -> translate_sensors becomes None and human_readable "" async def _translations(_hass, _domain, _key, **_kwargs): return None monkeypatch.setattr("custom_components.sws12500.translations", _translations) translated_notification = AsyncMock() monkeypatch.setattr( "custom_components.sws12500.translated_notification", translated_notification ) update_options = AsyncMock() monkeypatch.setattr("custom_components.sws12500.update_options", update_options) add_new_sensors = MagicMock() monkeypatch.setattr( "custom_components.sws12500.sensor.add_new_sensors", add_new_sensors ) coordinator.async_set_updated_data = MagicMock() resp = await coordinator.received_data( _RequestStub(query={"ID": "id", "PASSWORD": "key"}) ) # type: ignore[arg-type] assert resp.status == 200 translated_notification.assert_awaited() update_options.assert_awaited() add_new_sensors.assert_called_once() coordinator.async_set_updated_data.assert_called_once_with(remapped) @pytest.mark.asyncio async def test_received_data_dev_logging_calls_anonymize_and_logs(hass, monkeypatch): entry = _make_entry(wslink=False, api_id="id", api_key="key", dev_debug=True) coordinator = WeatherDataUpdateCoordinator(hass, entry) monkeypatch.setattr("custom_components.sws12500.remap_items", lambda _d: {"k": "v"}) monkeypatch.setattr( "custom_components.sws12500.check_disabled", lambda _remaped_items, _config: [], ) anonymize = MagicMock(return_value={"safe": True}) monkeypatch.setattr("custom_components.sws12500.anonymize", anonymize) log_info = MagicMock() monkeypatch.setattr("custom_components.sws12500._LOGGER.info", log_info) coordinator.async_set_updated_data = MagicMock() request = _RequestStub(query={"ID": "id", "PASSWORD": "key", "x": "y"}) resp = await coordinator.received_data(request) # type: ignore[arg-type] assert resp.status == 200 anonymize.assert_called_once() log_info.assert_called_once() @pytest.mark.asyncio async def test_register_path_switching_logic_is_exercised_via_routes(monkeypatch): """Sanity: constants exist and are distinct (helps guard tests relying on them).""" assert DEFAULT_URL != WSLINK_URL assert DOMAIN == "sws12500"