diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d4bd99d..1a4238ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [1.9.5] - 2026-02-27 +### Changed +- Aligned config-flow validation with runtime client semantics by using + `GooglePollenApiClient` for setup checks, mapping auth/quota/connectivity + errors consistently, and removing duplicate HTTP/JSON parsing from the flow. +- Expanded config-flow regression coverage to validate client-based setup + behavior, including normalized request arguments and timeout/client-error + handling parity. +- Bumped GitHub Actions artifact upload to `actions/upload-artifact@v7`. +- Replaced fragile text-based HTTP 429 detection in setup validation with a + dedicated client quota exception so `quota_exceeded` mapping remains stable. + ## [1.9.4] - 2026-02-24 ### Changed - Updated release packaging automation for HACS `zip_release` by validating the diff --git a/custom_components/pollenlevels/client.py b/custom_components/pollenlevels/client.py index f03cb6f3..39173716 100644 --- a/custom_components/pollenlevels/client.py +++ b/custom_components/pollenlevels/client.py @@ -30,6 +30,10 @@ def _format_http_message(status: int, raw_message: str | None) -> str: return f"HTTP {status}" +class PollenQuotaExceededError(UpdateFailed): + """Raised when Google Pollen API quota limits are exceeded (HTTP 429).""" + + class GooglePollenApiClient: """Thin async client wrapper for the Google Pollen API.""" @@ -136,7 +140,7 @@ async def async_fetch_pollen_data( await extract_error_message(resp, default=""), self._api_key ) message = _format_http_message(resp.status, raw_message or None) - raise UpdateFailed(message) + raise PollenQuotaExceededError(message) if 500 <= resp.status <= 599: if attempt < max_retries: diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index d0723b49..693072e8 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -13,16 +13,16 @@ from __future__ import annotations -import json import logging import re from typing import Any -import aiohttp import homeassistant.helpers.config_validation as cv import voluptuous as vol +from aiohttp import ClientError from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( LocationSelector, @@ -37,7 +37,9 @@ TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.update_coordinator import UpdateFailed +from .client import GooglePollenApiClient, PollenQuotaExceededError from .const import ( CONF_API_KEY, CONF_CREATE_FORECAST_SENSORS, @@ -56,10 +58,8 @@ POLLEN_API_KEY_URL, POLLEN_API_TIMEOUT, RESTRICTING_API_KEYS_URL, - is_invalid_api_key_message, ) from .util import ( - extract_error_message, normalize_sensor_mode, redact_api_key, safe_parse_int, @@ -398,71 +398,25 @@ async def _async_validate_input( lang = is_valid_language_code(lang) session = async_get_clientsession(self.hass) - params = { - "key": api_key, - "location.latitude": f"{lat:.6f}", - "location.longitude": f"{lon:.6f}", - "days": 1, - } - if lang: - params["languageCode"] = lang - - url = "https://pollen.googleapis.com/v1/forecast:lookup" - - _LOGGER.debug("Validating Pollen API (days=%s, lang_set=%s)", 1, bool(lang)) - - async with session.get( - url, - params=params, - timeout=aiohttp.ClientTimeout(total=POLLEN_API_TIMEOUT), - ) as resp: - status = resp.status - if status != 200: - _LOGGER.debug("Validation HTTP %s (body omitted)", status) - raw_msg = await extract_error_message(resp, f"HTTP {status}") - placeholders["error_message"] = redact_api_key(raw_msg, api_key) - if status == 401: - errors["base"] = "invalid_auth" - elif status == 403: - if is_invalid_api_key_message(raw_msg): - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" - elif status == 429: - errors["base"] = "quota_exceeded" - else: - errors["base"] = "cannot_connect" - else: - raw = await resp.read() - try: - body_str = raw.decode() - except Exception: - body_str = str(raw) - _LOGGER.debug( - "Validation HTTP %s — %s", - status, - redact_api_key(body_str, api_key), - ) - try: - data = json.loads(body_str) if body_str else {} - except Exception: - data = {} + client = GooglePollenApiClient(session, api_key) + data = await client.async_fetch_pollen_data( + latitude=lat, + longitude=lon, + days=1, + language_code=lang or None, + ) - 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." - ) + 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." + ) if errors: return errors, None @@ -478,6 +432,21 @@ async def _async_validate_input( ) errors[CONF_LANGUAGE_CODE] = _language_error_to_form_key(ve) placeholders.pop("error_message", None) + except ConfigEntryAuthFailed as err: + errors["base"] = "invalid_auth" + redacted = redact_api_key(err, api_key) + if redacted: + placeholders["error_message"] = redacted + except PollenQuotaExceededError as err: + redacted = redact_api_key(err, api_key) + if redacted: + placeholders["error_message"] = redacted + errors["base"] = "quota_exceeded" + except UpdateFailed as err: + redacted = redact_api_key(err, api_key) + if redacted: + placeholders["error_message"] = redacted + errors["base"] = "cannot_connect" except TimeoutError as err: _LOGGER.warning( "Validation timeout (%ss): %s", @@ -490,7 +459,7 @@ async def _async_validate_input( redacted or f"Validation request timed out ({POLLEN_API_TIMEOUT} seconds)." ) - except aiohttp.ClientError as err: + except ClientError as err: _LOGGER.error( "Connection error: %s", redact_api_key(err, api_key), diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 42f5c1d5..b815f1f4 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.9.4" + "version": "1.9.5" } diff --git a/pyproject.toml b/pyproject.toml index c3d891f0..91759c03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ [project] name = "pollenlevels" -version = "1.9.4" +version = "1.9.5" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index af1f78dc..691e05d6 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -44,6 +44,16 @@ def _force_module(name: str, module: ModuleType) -> None: ha_mod = ModuleType("homeassistant") _force_module("homeassistant", ha_mod) +exceptions_mod = ModuleType("homeassistant.exceptions") + + +class _StubConfigEntryAuthFailed(Exception): + pass + + +exceptions_mod.ConfigEntryAuthFailed = _StubConfigEntryAuthFailed +_force_module("homeassistant.exceptions", exceptions_mod) + config_entries_mod = ModuleType("homeassistant.config_entries") @@ -98,6 +108,16 @@ def __init__(self, data=None, options=None, entry_id="stub-entry"): helpers_mod = ModuleType("homeassistant.helpers") _force_module("homeassistant.helpers", helpers_mod) +update_coordinator_mod = ModuleType("homeassistant.helpers.update_coordinator") + + +class _StubUpdateFailed(Exception): + pass + + +update_coordinator_mod.UpdateFailed = _StubUpdateFailed +_force_module("homeassistant.helpers.update_coordinator", update_coordinator_mod) + config_validation_mod = ModuleType("homeassistant.helpers.config_validation") @@ -298,6 +318,34 @@ def __init__(self, schema): vol_mod.In = lambda *args, **kwargs: None _force_module("voluptuous", vol_mod) + +client_mod = ModuleType("custom_components.pollenlevels.client") + + +class _StubGooglePollenApiClient: + def __init__(self, session, api_key: str) -> None: + self._session = session + self._api_key = api_key + + async def async_fetch_pollen_data( + self, + *, + latitude: float, + longitude: float, + days: int, + language_code: str | None, + ) -> dict: + return {"dailyInfo": [{"day": "D0"}]} + + +class _StubPollenQuotaExceededError(_StubUpdateFailed): + pass + + +client_mod.GooglePollenApiClient = _StubGooglePollenApiClient +client_mod.PollenQuotaExceededError = _StubPollenQuotaExceededError +_force_module("custom_components.pollenlevels.client", client_mod) + from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, @@ -331,32 +379,66 @@ def __init__(self, status: int, body: bytes | None = None) -> None: self.status = status self._body = body or b"{}" - async def __aenter__(self): # pragma: no cover - trivial - return self - async def __aexit__(self, exc_type, exc, tb): # pragma: no cover - trivial - return None +class _StubValidationClient: + def __init__(self, responses: list[_StubResponse], api_key: str) -> None: + self._responses = responses + self._api_key = api_key + self.calls: list[dict[str, object]] = [] - async def read(self) -> bytes: - return self._body + async def async_fetch_pollen_data( + self, + *, + latitude: float, + longitude: float, + days: int, + language_code: str | None, + ) -> dict: + self.calls.append( + { + "latitude": latitude, + "longitude": longitude, + "days": days, + "language_code": language_code, + } + ) + response = self._responses.pop(0) + + if response.status == 401: + raise cf.ConfigEntryAuthFailed("HTTP 401: API key *** not valid") + + if response.status == 403: + body_text = response._body.decode() + if "API key not valid" in body_text: + raise cf.ConfigEntryAuthFailed(f"HTTP 403: {body_text}") + raise cf.UpdateFailed(f"HTTP 403: {body_text}") + + if response.status == 429: + raise cf.PollenQuotaExceededError("HTTP 429: Quota exceeded") + + if response.status != 200: + raise cf.UpdateFailed(f"HTTP {response.status}: backend error") - async def json(self): import json as _json - return _json.loads(self._body.decode()) + return _json.loads(response._body.decode()) - async def text(self) -> str: - return self._body.decode() +class _PatchedClientFactory: + def __init__(self, responses: list[_StubResponse]): + self._responses = responses + self.instances: list[_StubValidationClient] = [] -class _SequenceSession: - def __init__(self, responses: list[_StubResponse]) -> None: - self.responses = responses - self.calls: list[tuple[tuple, dict]] = [] + @property + def calls(self) -> list[dict[str, object]]: + if not self.instances: + return [] + return self.instances[0].calls - def get(self, *args, **kwargs): - self.calls.append((args, kwargs)) - return self.responses.pop(0) + def __call__(self, session, api_key: str) -> _StubValidationClient: + client = _StubValidationClient(self._responses, api_key) + self.instances.append(client) + return client def _collect_error_keys_from_config_flow() -> set[str]: @@ -555,9 +637,11 @@ def test_validate_input_non_dict_location() -> None: def _patch_client_session(monkeypatch: pytest.MonkeyPatch, response: _StubResponse): - session = _SequenceSession([response]) + session = object() monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) - return session + factory = _PatchedClientFactory([response]) + monkeypatch.setattr(cf, "GooglePollenApiClient", factory) + return factory def _base_user_input() -> dict: @@ -828,6 +912,60 @@ def test_validate_input_http_429_sets_quota_exceeded( assert normalized is None +def test_validate_input_client_timeout_maps_to_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Timeouts bubbling from the API client should map to cannot_connect.""" + + class _TimeoutClient: + def __init__(self, session, api_key: str) -> None: + self.calls: list[dict[str, object]] = [] + + async def async_fetch_pollen_data(self, **kwargs) -> dict: + self.calls.append(kwargs) + raise TimeoutError("timed out") + + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: object()) + monkeypatch.setattr(cf, "GooglePollenApiClient", _TimeoutClient) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert errors == {"base": "cannot_connect"} + assert normalized is None + + +def test_validate_input_client_error_maps_to_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Client transport errors should map to cannot_connect.""" + + class _ClientErroringClient: + def __init__(self, session, api_key: str) -> None: + self.calls: list[dict[str, object]] = [] + + async def async_fetch_pollen_data(self, **kwargs) -> dict: + self.calls.append(kwargs) + raise cf.ClientError("network down") + + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: object()) + monkeypatch.setattr(cf, "GooglePollenApiClient", _ClientErroringClient) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() + + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert errors == {"base": "cannot_connect"} + assert normalized is None + + def test_validate_input_http_500_sets_cannot_connect( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1104,8 +1242,11 @@ 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 = _SequenceSession([_StubResponse(200, body), _StubResponse(200, body)]) - monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) + factory = _PatchedClientFactory( + [_StubResponse(200, body), _StubResponse(200, body)] + ) + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: object()) + monkeypatch.setattr(cf, "GooglePollenApiClient", factory) class _TrackingFlow(PollenLevelsConfigFlow): def __init__(self) -> None: @@ -1135,7 +1276,9 @@ def _abort_if_unique_id_configured(self): flow._async_validate_input(user_input, check_unique_id=True) ) - assert session.calls + assert factory.calls + assert factory.calls[0]["days"] == 1 + assert factory.calls[0]["language_code"] == "es" assert errors == {} assert normalized is not None assert normalized[CONF_LATITUDE] == pytest.approx(1.0) @@ -1151,8 +1294,11 @@ def test_validate_input_unique_id_collapses_nearby_locations_legacy_compat( """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) + factory = _PatchedClientFactory( + [_StubResponse(200, body), _StubResponse(200, body)] + ) + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: object()) + monkeypatch.setattr(cf, "GooglePollenApiClient", factory) class _TrackingFlow(PollenLevelsConfigFlow): def __init__(self) -> None: @@ -1185,7 +1331,7 @@ def _abort_if_unique_id_configured(self): flow._async_validate_input(second, check_unique_id=True) ) - assert session.calls + assert factory.calls assert first_errors == {} assert second_errors == {} assert first_normalized is not None