diff --git a/CHANGELOG.md b/CHANGELOG.md index d86d65cd..f95f7015 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## [1.9.3] - 2026-02-14 ### Fixed +- Aligned config-flow API validation with runtime parsing by requiring `dailyInfo` + to be a non-empty list of objects during setup validation. +- Prevented test cross-contamination in setup tests by using scoped monkeypatching + for coordinator/client stubs instead of persistent module reassignment. +- Prevented disabled per-day sensors from being re-created during sensor setup by + skipping `*_d1`/`*_d2` keys when effective forecast options disable them. +- Hardened coordinator parsing for malformed `dailyInfo` payloads by treating + non-list/non-dict structures as invalid and preserving the last successful + dataset when available. +- Normalized stored forecast sensor mode values during integration setup so + legacy or whitespace-padded values no longer degrade silently to `none`. - Ensured deterministic current-day plant sensor creation by sorting plant codes. - Reject whitespace-only API keys at setup (defensive validation) and raise `ConfigEntryAuthFailed` with a clearer "Invalid API key" message. - Mask API key input fields in config flow (password selector). @@ -8,6 +19,13 @@ - Improved test isolation by avoiding unconditional replacement of the global `aiohttp` module stub. ### Changed +- Switched sensor setup iteration to use a validated local data snapshot for + clearer and more consistent entity creation flow. +- Preserved legacy 4-decimal coordinate unique-id formatting to keep existing + duplicate-location detection behavior stable across upgrades. +- Expanded regression coverage for disabled per-day sensor creation, malformed + `dailyInfo` handling, setup mode normalization, and legacy duplicate + detection behavior for coordinate-based unique IDs. - Simplified plant parsing by removing redundant code checks (non-empty by construction). - Deduplicated defensive integer parsing into a shared utility and aligned diagnostics with runtime/config-flow rules to reject non-finite or decimal values consistently. diff --git a/custom_components/pollenlevels/__init__.py b/custom_components/pollenlevels/__init__.py index 4b83e4b2..bd930a67 100644 --- a/custom_components/pollenlevels/__init__.py +++ b/custom_components/pollenlevels/__init__.py @@ -207,12 +207,15 @@ async def async_setup_entry( CONF_CREATE_FORECAST_SENSORS, entry.data.get(CONF_CREATE_FORECAST_SENSORS, ForecastSensorMode.NONE), ) + normalized_mode = normalize_sensor_mode(raw_mode, _LOGGER) try: - mode = ForecastSensorMode(raw_mode) + mode = ForecastSensorMode(normalized_mode) except (ValueError, TypeError): mode = ForecastSensorMode.NONE - create_d1 = mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) - create_d2 = mode == ForecastSensorMode.D1_D2 + create_d1 = ( + mode in (ForecastSensorMode.D1, ForecastSensorMode.D1_D2) and forecast_days >= 2 + ) + create_d2 = mode == ForecastSensorMode.D1_D2 and forecast_days >= 3 api_key = entry.data.get(CONF_API_KEY) if not isinstance(api_key, str) or not api_key.strip(): diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index 150df6a7..d0723b49 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -376,6 +376,8 @@ async def _async_validate_input( normalized[CONF_LONGITUDE] = lon if check_unique_id: + # Keep unique_id formatting aligned with legacy entries for + # duplicate detection compatibility across upgrades. uid = f"{lat:.4f}_{lon:.4f}" try: await self.async_set_unique_id(uid, raise_on_progress=False) @@ -445,8 +447,18 @@ async def _async_validate_input( data = json.loads(body_str) if body_str else {} except Exception: data = {} - if not data.get("dailyInfo"): - _LOGGER.warning("Validation: 'dailyInfo' missing") + + daily_info = ( + data.get("dailyInfo") if isinstance(data, dict) else None + ) + daily_is_valid = isinstance(daily_info, list) and bool(daily_info) + if daily_is_valid: + daily_is_valid = all( + isinstance(item, dict) for item in daily_info + ) + + if not daily_is_valid: + _LOGGER.warning("Validation: 'dailyInfo' missing or invalid") errors["base"] = "cannot_connect" placeholders["error_message"] = ( "API response missing expected pollen forecast information." diff --git a/custom_components/pollenlevels/coordinator.py b/custom_components/pollenlevels/coordinator.py index 5770aa6c..8f7051c8 100644 --- a/custom_components/pollenlevels/coordinator.py +++ b/custom_components/pollenlevels/coordinator.py @@ -238,16 +238,23 @@ async def _async_update_data(self): if region := payload.get("regionCode"): new_data["region"] = {"source": "meta", "value": region} - daily: list[dict] = payload.get("dailyInfo") or [] + daily_raw = payload.get("dailyInfo") + daily = daily_raw if isinstance(daily_raw, list) else None + # Keep day offsets stable: if any element is invalid, treat the payload as + # malformed instead of compacting/reindexing list positions. + if daily is not None and any(not isinstance(item, dict) for item in daily): + daily = None + if not daily: if self.data: if not self._missing_dailyinfo_warned: _LOGGER.warning( - "API response missing dailyInfo; keeping last successful data" + "API response missing or invalid dailyInfo; " + "keeping last successful data" ) self._missing_dailyinfo_warned = True return self.data - raise UpdateFailed("API response missing dailyInfo") + raise UpdateFailed("API response missing or invalid dailyInfo") self._missing_dailyinfo_warned = False # date (today) diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 97fbbcb7..c2a99272 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -189,9 +189,13 @@ async def async_setup_entry( ) sensors: list[CoordinatorEntity] = [] - for code in coordinator.data: + for code in data: if code in ("region", "date"): continue + if code.endswith("_d1") and not allow_d1: + continue + if code.endswith("_d2") and not allow_d2: + continue sensors.append(PollenSensor(coordinator, code)) sensors.extend( diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 6b04f7f3..af1f78dc 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1037,6 +1037,46 @@ def test_validate_input_redacts_api_key_in_error_message( assert "***" in error_message +def test_validate_input_http_200_non_list_dailyinfo_sets_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-list dailyInfo in HTTP 200 should be treated as invalid.""" + + body = b'{"dailyInfo": "invalid"}' + session = _patch_client_session(monkeypatch, _StubResponse(status=200, body=body)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert session.calls + assert errors == {"base": "cannot_connect"} + assert normalized is None + + +def test_validate_input_http_200_dailyinfo_with_non_dict_sets_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A dailyInfo list with non-dict items should be treated as invalid.""" + + body = b'{"dailyInfo": ["invalid-item"]}' + session = _patch_client_session(monkeypatch, _StubResponse(status=200, body=body)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert session.calls + assert errors == {"base": "cannot_connect"} + assert normalized is None + + def test_validate_input_unexpected_exception_sets_unknown( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1064,7 +1104,8 @@ def test_validate_input_happy_path_sets_unique_id_and_normalizes( """Successful validation should normalize data and set unique ID.""" body = b'{"dailyInfo": [{"day": "D0"}]}' - session = _patch_client_session(monkeypatch, _StubResponse(200, body)) + session = _SequenceSession([_StubResponse(200, body), _StubResponse(200, body)]) + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) class _TrackingFlow(PollenLevelsConfigFlow): def __init__(self) -> None: @@ -1104,6 +1145,55 @@ def _abort_if_unique_id_configured(self): assert flow.abort_calls == 1 +def test_validate_input_unique_id_collapses_nearby_locations_legacy_compat( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unique-id format should match legacy 4-decimal duplicate detection.""" + + body = b'{"dailyInfo": [{"day": "D0"}]}' + session = _SequenceSession([_StubResponse(200, body), _StubResponse(200, body)]) + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) + + class _TrackingFlow(PollenLevelsConfigFlow): + def __init__(self) -> None: + super().__init__() + self.unique_ids: list[str] = [] + + async def async_set_unique_id(self, uid: str, raise_on_progress: bool = False): + self.unique_ids.append(uid) + return None + + def _abort_if_unique_id_configured(self): + return None + + flow = _TrackingFlow() + flow.hass = SimpleNamespace(config=SimpleNamespace()) + + first = { + **_base_user_input(), + CONF_LOCATION: {CONF_LATITUDE: "1.0000044", CONF_LONGITUDE: "2.0000044"}, + } + second = { + **_base_user_input(), + CONF_LOCATION: {CONF_LATITUDE: "1.0000046", CONF_LONGITUDE: "2.0000046"}, + } + + first_errors, first_normalized = asyncio.run( + flow._async_validate_input(first, check_unique_id=True) + ) + second_errors, second_normalized = asyncio.run( + flow._async_validate_input(second, check_unique_id=True) + ) + + assert session.calls + assert first_errors == {} + assert second_errors == {} + assert first_normalized is not None + assert second_normalized is not None + assert len(flow.unique_ids) == 2 + assert flow.unique_ids[0] == flow.unique_ids[1] == "1.0000_2.0000" + + def test_reauth_confirm_updates_and_reloads_entry() -> None: """Re-auth confirmation should update stored credentials and reload the entry.""" diff --git a/tests/test_init.py b/tests/test_init.py index 082a5be5..40788381 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -480,7 +480,9 @@ def test_setup_entry_boundary_coordinates_are_allowed() -> None: assert asyncio.run(integration.async_setup_entry(hass, entry)) is True -def test_setup_entry_decimal_numeric_options_fallback_to_defaults() -> None: +def test_setup_entry_decimal_numeric_options_fallback_to_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Decimal options should not be truncated silently during setup.""" hass = _FakeHass() @@ -507,15 +509,11 @@ def __init__(self, *args, **kwargs): async def async_config_entry_first_refresh(self): return None - orig_coordinator = integration.PollenDataUpdateCoordinator - integration.PollenDataUpdateCoordinator = _StubCoordinator + monkeypatch.setattr(integration, "PollenDataUpdateCoordinator", _StubCoordinator) - try: - assert asyncio.run(integration.async_setup_entry(hass, entry)) is True - assert seen["hours"] == integration.DEFAULT_UPDATE_INTERVAL - assert seen["forecast_days"] == integration.DEFAULT_FORECAST_DAYS - finally: - integration.PollenDataUpdateCoordinator = orig_coordinator + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + assert seen["hours"] == integration.DEFAULT_UPDATE_INTERVAL + assert seen["forecast_days"] == integration.DEFAULT_FORECAST_DAYS def test_setup_entry_wraps_generic_error() -> None: @@ -531,7 +529,9 @@ class _Boom(Exception): asyncio.run(integration.async_setup_entry(hass, entry)) -def test_setup_entry_success_and_unload() -> None: +def test_setup_entry_success_and_unload( + monkeypatch: pytest.MonkeyPatch, +) -> None: """Happy path should forward setup, register listener, and unload cleanly.""" hass = _FakeHass() @@ -565,8 +565,8 @@ async def async_config_entry_first_refresh(self): async def async_refresh(self): return None - integration.GooglePollenApiClient = _StubClient - integration.PollenDataUpdateCoordinator = _StubCoordinator + monkeypatch.setattr(integration, "GooglePollenApiClient", _StubClient) + monkeypatch.setattr(integration, "PollenDataUpdateCoordinator", _StubCoordinator) assert asyncio.run(integration.async_setup_entry(hass, entry)) is True @@ -585,6 +585,89 @@ async def async_refresh(self): assert entry.runtime_data is None +def test_setup_entry_normalizes_forecast_sensor_mode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Setup should normalize stored forecast mode values before coordinator flags.""" + + hass = _FakeHass() + entry = _FakeEntry(options={integration.CONF_CREATE_FORECAST_SENSORS: " D+1 "}) + + class _StubClient: + def __init__(self, _session, _api_key): + self.session = _session + self.api_key = _api_key + + async def async_fetch_pollen_data(self, **_kwargs): + return {"region": {"source": "meta"}, "dailyInfo": []} + + class _StubCoordinator(update_coordinator_mod.DataUpdateCoordinator): + def __init__(self, *args, **kwargs): + self.create_d1 = kwargs["create_d1"] + self.create_d2 = kwargs["create_d2"] + self.entry_id = kwargs["entry_id"] + self.entry_title = kwargs.get("entry_title") + self.lat = kwargs["lat"] + self.lon = kwargs["lon"] + self.last_updated = None + self.data = {"region": {"source": "meta"}, "date": {"source": "meta"}} + + async def async_config_entry_first_refresh(self): + return None + + monkeypatch.setattr(integration, "GooglePollenApiClient", _StubClient) + monkeypatch.setattr(integration, "PollenDataUpdateCoordinator", _StubCoordinator) + + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + assert entry.runtime_data is not None + assert entry.runtime_data.coordinator.create_d1 is True + assert entry.runtime_data.coordinator.create_d2 is False + + +def test_setup_entry_disables_d1_when_forecast_days_is_one( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Setup should disable D+1/D+2 creation when forecast days disallow them.""" + + hass = _FakeHass() + entry = _FakeEntry( + options={ + integration.CONF_CREATE_FORECAST_SENSORS: "D+1+2", + integration.CONF_FORECAST_DAYS: 1, + } + ) + + class _StubClient: + def __init__(self, _session, _api_key): + self.session = _session + self.api_key = _api_key + + async def async_fetch_pollen_data(self, **_kwargs): + return {"region": {"source": "meta"}, "dailyInfo": []} + + class _StubCoordinator(update_coordinator_mod.DataUpdateCoordinator): + def __init__(self, *args, **kwargs): + self.create_d1 = kwargs["create_d1"] + self.create_d2 = kwargs["create_d2"] + self.entry_id = kwargs["entry_id"] + self.entry_title = kwargs.get("entry_title") + self.lat = kwargs["lat"] + self.lon = kwargs["lon"] + self.last_updated = None + self.data = {"region": {"source": "meta"}, "date": {"source": "meta"}} + + async def async_config_entry_first_refresh(self): + return None + + monkeypatch.setattr(integration, "GooglePollenApiClient", _StubClient) + monkeypatch.setattr(integration, "PollenDataUpdateCoordinator", _StubCoordinator) + + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + assert entry.runtime_data is not None + assert entry.runtime_data.coordinator.create_d1 is False + assert entry.runtime_data.coordinator.create_d2 is False + + def test_force_update_requests_refresh_per_entry() -> None: """force_update should queue refresh via runtime_data coordinators and skip missing runtime data.""" diff --git a/tests/test_sensor.py b/tests/test_sensor.py index f6ae122f..570aaeca 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -604,6 +604,153 @@ def test_coordinator_first_refresh_missing_dailyinfo_raises() -> None: assert coordinator.data == {} +def test_coordinator_first_refresh_invalid_dailyinfo_type_raises() -> None: + """Non-list dailyInfo payload should raise UpdateFailed on first refresh.""" + + session = SequenceSession([ResponseSpec(status=200, payload={"dailyInfo": {}})]) + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + with pytest.raises(client_mod.UpdateFailed, match="dailyInfo"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + +def test_coordinator_invalid_dailyinfo_items_keep_last_data() -> None: + """Invalid dailyInfo items should preserve previous successful coordinator data.""" + + session = SequenceSession( + [ + ResponseSpec( + status=200, + payload={ + "dailyInfo": [ + { + "date": {"year": 2025, "month": 5, "day": 9}, + "pollenTypeInfo": [ + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": {"value": 2, "category": "LOW"}, + } + ], + } + ] + }, + ), + ResponseSpec(status=200, payload={"dailyInfo": ["bad-item"]}), + ] + ) + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + first_data = loop.run_until_complete(coordinator._async_update_data()) + coordinator.data = first_data + second_data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert first_data["type_grass"]["value"] == 2 + assert second_data == first_data + + +def test_coordinator_mixed_dailyinfo_items_keep_last_data() -> None: + """Mixed valid/invalid dailyInfo items are treated as invalid payload.""" + + session = SequenceSession( + [ + ResponseSpec( + status=200, + payload={ + "dailyInfo": [ + { + "date": {"year": 2025, "month": 5, "day": 9}, + "pollenTypeInfo": [ + { + "code": "GRASS", + "displayName": "Grass", + "indexInfo": {"value": 2, "category": "LOW"}, + } + ], + } + ] + }, + ), + ResponseSpec( + status=200, + payload={ + "dailyInfo": [ + { + "date": {"year": 2025, "month": 5, "day": 10}, + "pollenTypeInfo": [], + }, + "bad-item", + ] + }, + ), + ] + ) + client = client_mod.GooglePollenApiClient(session, "test") + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="test", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=2, + create_d1=False, + create_d2=False, + client=client, + ) + + try: + first_data = loop.run_until_complete(coordinator._async_update_data()) + coordinator.data = first_data + second_data = loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert second_data == first_data + + def test_coordinator_clamps_forecast_days_negative() -> None: """Negative forecast days are clamped to minimum.""" @@ -1472,6 +1619,67 @@ async def _noop_add_entities(_entities, _update_before_add=False): loop.close() +@pytest.mark.asyncio +async def test_async_setup_entry_skips_disabled_d1_d2_sensors() -> None: + """Setup does not recreate D+1/D+2 sensors when forecast days disable them.""" + + hass = DummyHass(asyncio.get_running_loop()) + config_entry = FakeConfigEntry( + data={ + sensor.CONF_API_KEY: "key", + sensor.CONF_LATITUDE: 1.0, + sensor.CONF_LONGITUDE: 2.0, + sensor.CONF_UPDATE_INTERVAL: sensor.DEFAULT_UPDATE_INTERVAL, + sensor.CONF_FORECAST_DAYS: sensor.DEFAULT_FORECAST_DAYS, + }, + options={sensor.CONF_FORECAST_DAYS: 1}, + entry_id="entry", + ) + + client = client_mod.GooglePollenApiClient(FakeSession({}), "key") + coordinator = coordinator_mod.PollenDataUpdateCoordinator( + hass=hass, + api_key="key", + lat=1.0, + lon=2.0, + hours=sensor.DEFAULT_UPDATE_INTERVAL, + language=None, + entry_id="entry", + entry_title=sensor.DEFAULT_ENTRY_TITLE, + forecast_days=3, + create_d1=True, + create_d2=True, + client=client, + ) + coordinator.data = { + "date": {"source": "meta"}, + "region": {"source": "meta"}, + "type_grass": {"source": "type", "name": "Grass"}, + "type_grass_d1": {"source": "type", "name": "Grass D+1"}, + "type_grass_d2": {"source": "type", "name": "Grass D+2"}, + } + config_entry.runtime_data = sensor.PollenLevelsRuntimeData( + coordinator=coordinator, client=client + ) + + captured: list[Any] = [] + + def _capture_entities(entities, _update_before_add=False): + captured.extend(entities) + + await sensor.async_setup_entry(hass, config_entry, _capture_entities) + + unique_ids = { + entity.unique_id + for entity in captured + if getattr(entity, "unique_id", None) is not None + } + + assert "entry_type_grass" in unique_ids + assert all(not uid.endswith("_d1") for uid in unique_ids) + assert all(not uid.endswith("_d2") for uid in unique_ids) + + @pytest.mark.asyncio async def test_device_info_uses_default_title_when_blank( monkeypatch: pytest.MonkeyPatch,