diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index b5716a2b..6b1e1951 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -20,13 +20,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.event.inputs.target_branch || github.ref }} - name: Setup Python 3.14 (latest patch) - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.14" cache: "pip" diff --git a/.github/workflows/hassfest.yml b/.github/workflows/hassfest.yml index 9ecc28ed..5f4fd925 100644 --- a/.github/workflows/hassfest.yml +++ b/.github/workflows/hassfest.yml @@ -2,6 +2,7 @@ name: Validate with hassfest on: push: + branches: [main, master] pull_request: schedule: - cron: "0 0 * * *" @@ -17,7 +18,7 @@ jobs: steps: # Checkout so hassfest can read manifests and structure - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 # Official hassfest action (Home Assistant recommends @master) - name: Run hassfest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8f229db5..30a491ad 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,7 @@ name: Lint on: push: + branches: [main, master] pull_request: workflow_dispatch: inputs: @@ -22,13 +23,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.event.inputs.ref || github.ref }} - name: Setup Python 3.14 (latest patch) - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.14" cache: "pip" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 51066d9f..516defc4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,10 +21,10 @@ jobs: python-version: ["3.13", "3.14"] steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -37,6 +37,13 @@ jobs: python -m pip install -r requirements_test.txt fi + - name: Install package + if: ${{ matrix.python-version >= '3.14' }} + run: | + python -m pip install . + - name: Run tests run: | pytest -q + env: + PYTHONPATH: . diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6699b081..efa2d283 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -2,6 +2,7 @@ name: Validate with HACS on: push: + branches: [main, master] pull_request: schedule: - cron: "0 0 * * *" diff --git a/CHANGELOG.md b/CHANGELOG.md index 97afffe0..08333874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,13 @@ # Changelog +## [1.8.4] - 2025-11-27 +### Fixed +- Added a bounds check to the sensor test SequenceSession helper so exhausting the + fake response list raises a clear AssertionError instead of an IndexError. +- Added metadata consistency tests to keep the package name/version aligned with + the manifest and catch missing fields early in CI. +- Corrected the manifest JSON formatting so metadata parses cleanly without + trailing separators. + ## [1.8.3] - 2025-11-24 ### Fixed - Stop logging coordinate values through the invalid-coordinate exception message in the config diff --git a/custom_components/pollenlevels/config_flow.py b/custom_components/pollenlevels/config_flow.py index dfb26a0a..a56d5501 100644 --- a/custom_components/pollenlevels/config_flow.py +++ b/custom_components/pollenlevels/config_flow.py @@ -214,7 +214,7 @@ async def _async_validate_input( errors["base"] = "cannot_connect" except Exception as err: # defensive _LOGGER.exception( - "Unexpected error: %s", + "Unexpected error in Pollen Levels config flow while validating input: %s", redact_api_key(err, user_input.get(CONF_API_KEY)), ) errors["base"] = "cannot_connect" diff --git a/custom_components/pollenlevels/manifest.json b/custom_components/pollenlevels/manifest.json index 1a209e85..a9285bdf 100644 --- a/custom_components/pollenlevels/manifest.json +++ b/custom_components/pollenlevels/manifest.json @@ -1,11 +1,11 @@ { - "domain": "pollenlevels", - "name": "Pollen Levels", - "codeowners": ["@eXPerience83"], - "config_flow": true, - "documentation": "https://github.com/eXPerience83/pollenlevels", - "integration_type": "service", - "iot_class": "cloud_polling", - "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", - "version": "1.8.3" + "domain": "pollenlevels", + "name": "Pollen Levels", + "codeowners": ["@eXPerience83"], + "config_flow": true, + "documentation": "https://github.com/eXPerience83/pollenlevels", + "integration_type": "service", + "iot_class": "cloud_polling", + "issue_tracker": "https://github.com/eXPerience83/pollenlevels/issues", + "version": "1.8.4" } diff --git a/custom_components/pollenlevels/sensor.py b/custom_components/pollenlevels/sensor.py index 91183cef..e4451612 100644 --- a/custom_components/pollenlevels/sensor.py +++ b/custom_components/pollenlevels/sensor.py @@ -208,11 +208,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): allow_d1 = create_d1 and forecast_days >= 2 allow_d2 = create_d2 and forecast_days >= 3 - # Proactively remove stale D+ entities from the Entity Registry - await _cleanup_per_day_entities( - hass, config_entry.entry_id, allow_d1=allow_d1, allow_d2=allow_d2 - ) - coordinator = PollenDataUpdateCoordinator( hass=hass, api_key=api_key, @@ -236,6 +231,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _LOGGER.warning(message) raise ConfigEntryNotReady(message) + # Proactively remove stale D+ entities from the Entity Registry + await _cleanup_per_day_entities( + hass, config_entry.entry_id, allow_d1=allow_d1, allow_d2=allow_d2 + ) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator sensors: list[CoordinatorEntity] = [] @@ -463,6 +463,8 @@ async def _async_update_data(self): await asyncio.sleep(delay) continue msg = redact_api_key(err, self.api_key) + if not msg: + msg = "Google Pollen API call timed out" raise UpdateFailed(f"Timeout: {msg}") from err except aiohttp.ClientError as err: @@ -478,6 +480,8 @@ async def _async_update_data(self): await asyncio.sleep(delay) continue msg = redact_api_key(err, self.api_key) + if not msg: + msg = "Network error while calling the Google Pollen API" raise UpdateFailed(msg) from err except Exception as err: # Keep previous behavior for unexpected errors diff --git a/pyproject.toml b/pyproject.toml index eb750135..8a411fad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,8 @@ # - CI: pip install "black==" "ruff==" [project] +name = "pollenlevels" +version = "1.8.4" # Enforce the runtime floor aligned with upcoming HA Python 3.14 images. requires-python = ">=3.14" diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..5db72dd6 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 92ca125f..8d8355fb 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -4,12 +4,14 @@ from __future__ import annotations +import ast import asyncio -import json import sys from pathlib import Path from types import ModuleType, SimpleNamespace +import pytest + ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) @@ -76,14 +78,45 @@ def __init__(self, data=None, options=None, entry_id="stub-entry"): sys.modules.setdefault("homeassistant.helpers.config_validation", config_validation_mod) aiohttp_client_mod = ModuleType("homeassistant.helpers.aiohttp_client") -aiohttp_client_mod.async_get_clientsession = lambda hass: SimpleNamespace( - get=lambda *args, **kwargs: SimpleNamespace( - __aenter__=lambda self: self, - __aexit__=lambda self, exc_type, exc, tb: None, - read=lambda: b"{}", - status=200, - ) -) + + +class _StubResponse: + """Async response stub matching aiohttp.ClientResponse for tests.""" + + def __init__(self, *, status: int = 200, body: bytes = b"{}") -> None: + self.status = status + self._body = body + + async def read(self) -> bytes: + """Return the fake response body.""" + + return self._body + + async def __aenter__(self) -> _StubResponse: + """Support the async context manager protocol.""" + + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + """Support the async context manager protocol.""" + + return None + + +class _StubSession: + """Async session stub exposing a get() method.""" + + def __init__(self, *, status: int = 200, body: bytes = b"{}") -> None: + self._status = status + self._body = body + + def get(self, *args, **kwargs) -> _StubResponse: + """Return an async context manager response stub.""" + + return _StubResponse(status=self._status, body=self._body) + + +aiohttp_client_mod.async_get_clientsession = lambda hass: _StubSession() sys.modules.setdefault("homeassistant.helpers.aiohttp_client", aiohttp_client_mod) ha_mod.helpers = helpers_mod @@ -121,6 +154,7 @@ def __init__(self, error_message=""): vol_mod.All = lambda *args, **kwargs: None vol_mod.Coerce = lambda *args, **kwargs: None vol_mod.Range = lambda *args, **kwargs: None +vol_mod.In = lambda *args, **kwargs: None sys.modules.setdefault("voluptuous", vol_mod) from custom_components.pollenlevels import config_flow as cf @@ -139,6 +173,91 @@ def __init__(self, error_message=""): ) +class _StubResponse: + 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 + + async def read(self) -> bytes: + return self._body + + +class _SequenceSession: + def __init__(self, responses: list[_StubResponse]) -> None: + self.responses = responses + self.calls: list[tuple[tuple, dict]] = [] + + def get(self, *args, **kwargs): + self.calls.append((args, kwargs)) + return self.responses.pop(0) + + +def _collect_error_keys_from_config_flow() -> set[str]: + """Parse the config flow to extract all error keys used in forms.""" + + source = (ROOT / "custom_components" / "pollenlevels" / "config_flow.py").read_text( + encoding="utf-8" + ) + tree = ast.parse(source) + + language_error_returns: set[str] = set() + + class _LanguageErrorVisitor(ast.NodeVisitor): + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802 + if node.name == "_language_error_to_form_key": + for child in ast.walk(node): + if ( + isinstance(child, ast.Return) + and isinstance(child.value, ast.Constant) + and isinstance(child.value.value, str) + ): + language_error_returns.add(child.value.value) + + _LanguageErrorVisitor().visit(tree) + + error_keys: set[str] = set() + + def _extract_error_values(value: ast.AST) -> set[str]: + values: set[str] = set() + if isinstance(value, ast.Constant) and isinstance(value.value, str): + values.add(value.value) + elif ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == "_language_error_to_form_key" + ): + values.update(language_error_returns) + return values + + class _ErrorsVisitor(ast.NodeVisitor): + def visit_Assign(self, node: ast.Assign) -> None: # noqa: N802 + for target in node.targets: + self._record_errors(target, node.value) + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: # noqa: N802 + self._record_errors(node.target, node.value) + self.generic_visit(node) + + def _record_errors(self, target: ast.AST, value: ast.AST | None) -> None: + if ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == "errors" + and value is not None + ): + error_keys.update(_extract_error_values(value)) + + _ErrorsVisitor().visit(tree) + return error_keys + + def test_validate_input_invalid_language_key_mapping() -> None: """Invalid language formats should surface the translation key.""" @@ -213,21 +332,122 @@ def test_validate_input_out_of_range_coordinates() -> None: assert normalized is None -def test_translations_define_required_error_keys() -> None: - """Every translation must expose the custom error messages.""" +def _patch_client_session(monkeypatch: pytest.MonkeyPatch, response: _StubResponse): + session = _SequenceSession([response]) + monkeypatch.setattr(cf, "async_get_clientsession", lambda hass: session) + return session + + +def _base_user_input() -> dict: + return { + CONF_API_KEY: "test-key", + CONF_LATITUDE: "1.0", + CONF_LONGITUDE: "2.0", + } + + +def test_validate_input_http_403_sets_invalid_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 403 during validation should map to invalid_auth.""" + + session = _patch_client_session(monkeypatch, _StubResponse(403)) + + flow = PollenLevelsConfigFlow() + flow.hass = SimpleNamespace() - translations_dir = ROOT / "custom_components" / "pollenlevels" / "translations" - required_errors = {"invalid_language_format", "invalid_coordinates"} + errors, normalized = asyncio.run( + flow._async_validate_input(_base_user_input(), check_unique_id=False) + ) + + assert session.calls + assert errors == {"base": "invalid_auth"} + assert normalized is None + + +def test_validate_input_http_429_sets_quota_exceeded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """HTTP 429 during validation should map to quota_exceeded.""" + + session = _patch_client_session(monkeypatch, _StubResponse(429)) + + 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": "quota_exceeded"} + assert normalized is None + + +def test_validate_input_http_500_sets_cannot_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Unexpected HTTP failures should map to cannot_connect.""" + + session = _patch_client_session(monkeypatch, _StubResponse(500)) + + 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_happy_path_sets_unique_id_and_normalizes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Successful validation should normalize data and set unique ID.""" + + body = b'{"dailyInfo": [{"day": "D0"}]}' + session = _patch_client_session(monkeypatch, _StubResponse(200, body)) + + class _TrackingFlow(PollenLevelsConfigFlow): + def __init__(self) -> None: + super().__init__() + self.unique_ids: list[str] = [] + self.abort_calls = 0 + + 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): + self.abort_calls += 1 + return None + + flow = _TrackingFlow() + flow.hass = SimpleNamespace( + config=SimpleNamespace(), + ) + + user_input = { + **_base_user_input(), + CONF_LANGUAGE_CODE: " es ", + CONF_ENTRY_NAME: "Name", + } + + errors, normalized = asyncio.run( + flow._async_validate_input(user_input, check_unique_id=True) + ) - for path in translations_dir.glob("*.json"): - content = json.loads(path.read_text(encoding="utf-8")) - for section in ("config", "options"): - errors = content.get(section, {}).get("error", {}) - for key in required_errors: - assert key in errors, f"missing {key} in {path.name} ({section})" - assert errors[ - key - ].strip(), f"empty {key} message in {path.name} ({section})" + assert session.calls + assert errors == {} + assert normalized is not None + assert normalized[CONF_LATITUDE] == pytest.approx(1.0) + assert normalized[CONF_LONGITUDE] == pytest.approx(2.0) + assert normalized[CONF_LANGUAGE_CODE] == "es" + assert flow.unique_ids == ["1.0000_2.0000"] + assert flow.abort_calls == 1 def test_reauth_confirm_updates_and_reloads_entry() -> None: diff --git a/tests/test_init.py b/tests/test_init.py index 6d1dc505..4ac49a95 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -63,15 +63,29 @@ class _StubConfigEntryAuthFailed(Exception): class _FakeConfigEntries: - def __init__(self, forward_exception: Exception | None = None): + def __init__( + self, + forward_exception: Exception | None = None, + unload_result: bool = True, + ): self._forward_exception = forward_exception + self._unload_result = unload_result self.forward_calls: list[tuple[object, list[str]]] = [] + self.unload_calls: list[tuple[object, list[str]]] = [] + self.reload_calls: list[str] = [] async def async_forward_entry_setups(self, entry, platforms): self.forward_calls.append((entry, platforms)) if self._forward_exception is not None: raise self._forward_exception + async def async_unload_platforms(self, entry, platforms): + self.unload_calls.append((entry, platforms)) + return self._unload_result + + async def async_reload(self, entry_id: str): # pragma: no cover - used in tests + self.reload_calls.append(entry_id) + class _FakeEntry: def __init__(self, *, entry_id: str = "entry-1", title: str = "Pollen Levels"): @@ -116,3 +130,24 @@ class _Boom(Exception): with pytest.raises(integration.ConfigEntryNotReady): asyncio.run(integration.async_setup_entry(hass, entry)) + + +def test_setup_entry_success_and_unload() -> None: + """Happy path should forward setup, register listener, and unload cleanly.""" + + hass = _FakeHass() + entry = _FakeEntry() + hass.data[integration.DOMAIN] = {entry.entry_id: "coordinator"} + + assert asyncio.run(integration.async_setup_entry(hass, entry)) is True + + assert hass.config_entries.forward_calls == [(entry, ["sensor"])] + assert entry._update_listener is integration._update_listener # noqa: SLF001 + assert entry._on_unload is entry._update_listener # noqa: SLF001 + + asyncio.run(entry._update_listener(hass, entry)) # noqa: SLF001 + assert hass.config_entries.reload_calls == [entry.entry_id] + + assert asyncio.run(integration.async_unload_entry(hass, entry)) is True + assert hass.config_entries.unload_calls == [(entry, ["sensor"])] + assert hass.data[integration.DOMAIN] == {} diff --git a/tests/test_metadata.py b/tests/test_metadata.py new file mode 100644 index 00000000..b7f19581 --- /dev/null +++ b/tests/test_metadata.py @@ -0,0 +1,40 @@ +"""Project metadata consistency tests.""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PYPROJECT_PATH = ROOT / "pyproject.toml" +MANIFEST_PATH = ROOT / "custom_components" / "pollenlevels" / "manifest.json" + + +def _load_pyproject() -> dict: + with PYPROJECT_PATH.open("rb") as file: + return tomllib.load(file) + + +def _load_manifest() -> dict: + with MANIFEST_PATH.open("r", encoding="utf-8") as file: + return json.load(file) + + +def test_pyproject_declares_name_and_version() -> None: + """Ensure pyproject defines package metadata required for installs.""" + project = _load_pyproject().get("project") + assert project, "[project] section missing in pyproject.toml" + + assert project.get("name"), "Project name must be defined for packaging" + assert project.get("version"), "Project version must be defined for packaging" + + +def test_manifest_version_matches_pyproject() -> None: + """Manifest version should stay aligned with the package metadata.""" + project = _load_pyproject().get("project", {}) + manifest = _load_manifest() + + assert manifest.get("version") == project.get( + "version" + ), "Manifest version must match pyproject version" diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py new file mode 100644 index 00000000..81afa947 --- /dev/null +++ b/tests/test_options_flow.py @@ -0,0 +1,130 @@ +"""Options flow validation tests for Pollen Levels.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +import pytest + +from custom_components.pollenlevels.const import ( + CONF_API_KEY, + CONF_CREATE_FORECAST_SENSORS, + CONF_FORECAST_DAYS, + CONF_LANGUAGE_CODE, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_UPDATE_INTERVAL, +) +from tests import test_config_flow as base + +PollenLevelsOptionsFlow = base.cf.PollenLevelsOptionsFlow +_StubConfigEntry = base._StubConfigEntry + + +def _flow(entry_data: dict | None = None, options: dict | None = None): + entry = _StubConfigEntry( + data=entry_data + or { + CONF_API_KEY: "key", + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 2.0, + CONF_LANGUAGE_CODE: "en", + CONF_UPDATE_INTERVAL: 6, + CONF_FORECAST_DAYS: 2, + CONF_CREATE_FORECAST_SENSORS: "none", + }, + options=options, + ) + flow = PollenLevelsOptionsFlow(entry) + flow.hass = SimpleNamespace(config=SimpleNamespace(language="en")) + flow.async_show_form = lambda **kwargs: kwargs + flow.async_create_entry = lambda *, title, data: {"title": title, "data": data} + return flow + + +def test_options_flow_invalid_language_sets_error() -> None: + """Invalid language in options should map to invalid_language_format.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "bad code", + CONF_FORECAST_DAYS: 2, + CONF_CREATE_FORECAST_SENSORS: "none", + CONF_UPDATE_INTERVAL: 6, + } + ) + ) + + assert result["errors"] == {CONF_LANGUAGE_CODE: "invalid_language_format"} + + +def test_options_flow_forecast_days_below_min_sets_error() -> None: + """Forecast days below allowed range should error.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "en", + CONF_FORECAST_DAYS: 0, + CONF_CREATE_FORECAST_SENSORS: "none", + CONF_UPDATE_INTERVAL: 6, + } + ) + ) + + assert result["errors"] == { + CONF_FORECAST_DAYS: "invalid_option_combo", + CONF_CREATE_FORECAST_SENSORS: "invalid_option_combo", + } + + +@pytest.mark.parametrize( + "mode,days", + [("D+1", 1), ("D+1+2", 2)], +) +def test_options_flow_per_day_sensor_requires_enough_days(mode: str, days: int) -> None: + """Per-day sensor modes should enforce minimum forecast days.""" + + flow = _flow() + + result = asyncio.run( + flow.async_step_init( + { + CONF_LANGUAGE_CODE: "en", + CONF_FORECAST_DAYS: days, + CONF_CREATE_FORECAST_SENSORS: mode, + CONF_UPDATE_INTERVAL: 6, + } + ) + ) + + assert result["errors"] == {CONF_CREATE_FORECAST_SENSORS: "invalid_option_combo"} + + +def test_options_flow_valid_submission_returns_entry_data() -> None: + """A valid options submission should return the data unchanged.""" + + flow = _flow() + + user_input = { + CONF_LANGUAGE_CODE: " es ", + CONF_FORECAST_DAYS: 3, + CONF_CREATE_FORECAST_SENSORS: "D+1", + CONF_UPDATE_INTERVAL: 8, + } + + result = asyncio.run(flow.async_step_init(dict(user_input))) + + assert result == { + "title": "", + "data": { + **user_input, + CONF_LANGUAGE_CODE: "es", + }, + } diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b3d94783..152db35e 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -7,7 +7,7 @@ import sys import types from pathlib import Path -from typing import Any +from typing import Any, NamedTuple import pytest @@ -138,9 +138,11 @@ def __init__(self, coordinator): def _stub_utcnow(): - from datetime import datetime + """Return a timezone-aware UTC datetime, similar to Home Assistant.""" - return datetime.utcnow() + from datetime import UTC, datetime + + return datetime.now(UTC) dt_mod.utcnow = _stub_utcnow @@ -208,10 +210,16 @@ def __init__( class FakeResponse: """Async context manager returning a static payload.""" - def __init__(self, payload: dict[str, Any], *, status: int = 200) -> None: + def __init__( + self, + payload: dict[str, Any], + *, + status: int = 200, + headers: dict[str, str] | None = None, + ) -> None: self._payload = payload self.status = status - self.headers: dict[str, str] = {} + self.headers: dict[str, str] = headers or {} async def json(self) -> dict[str, Any]: return self._payload @@ -239,6 +247,38 @@ def get(self, *_args, **_kwargs) -> FakeResponse: return FakeResponse(self._payload, status=self._status) +class ResponseSpec(NamedTuple): + """Describe a fake HTTP response to return from the coordinator session.""" + + status: int + payload: dict[str, Any] + headers: dict[str, str] | None = None + + +class SequenceSession: + """Session that returns a sequence of responses or raises exceptions.""" + + def __init__(self, sequence: list[ResponseSpec | Exception]): + self.sequence = sequence + self.calls = 0 + + def get(self, *_args, **_kwargs): + if self.calls >= len(self.sequence): + raise AssertionError( + "SequenceSession exhausted; no more responses " + f"(calls={self.calls}, sequence_len={len(self.sequence)})." + ) + item = self.sequence[self.calls] + self.calls += 1 + + if isinstance(item, Exception): + raise item + + return FakeResponse( + item.payload, status=item.status, headers=item.headers or {} + ) + + class RegistryEntry: """Simple stub representing an Entity Registry entry.""" @@ -621,6 +661,175 @@ def test_coordinator_raises_auth_failed(monkeypatch: pytest.MonkeyPatch) -> None loop.close() +def test_coordinator_retries_then_raises_on_rate_limit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """429 responses are retried once then raise UpdateFailed with quota message.""" + + session = SequenceSession( + [ + ResponseSpec(status=429, payload={}, headers={"Retry-After": "3"}), + ResponseSpec(status=429, payload={}, headers={"Retry-After": "3"}), + ] + ) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.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, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="Quota exceeded"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [3.0] + + +def test_coordinator_retries_then_raises_on_server_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """5xx responses retry once before raising UpdateFailed with status.""" + + session = SequenceSession( + [ResponseSpec(status=500, payload={}), ResponseSpec(status=502, payload={})] + ) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.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, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="HTTP 502"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [0.8] + + +def test_coordinator_retries_then_wraps_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Timeout errors retry once then surface as UpdateFailed with context.""" + + session = SequenceSession([TimeoutError("boom"), TimeoutError("boom")]) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.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, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="Timeout"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [0.8] + + +def test_coordinator_retries_then_wraps_client_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Client errors retry once then raise UpdateFailed with redacted message.""" + + session = SequenceSession( + [sensor.aiohttp.ClientError("net down"), sensor.aiohttp.ClientError("net down")] + ) + delays: list[float] = [] + + async def _fast_sleep(delay: float) -> None: + delays.append(delay) + + monkeypatch.setattr(sensor.asyncio, "sleep", _fast_sleep) + monkeypatch.setattr(sensor.random, "uniform", lambda *_args, **_kwargs: 0.0) + monkeypatch.setattr(sensor, "async_get_clientsession", lambda _hass: session) + + loop = asyncio.new_event_loop() + hass = DummyHass(loop) + coordinator = sensor.PollenDataUpdateCoordinator( + hass=hass, + api_key="secret", + lat=1.0, + lon=2.0, + hours=12, + language=None, + entry_id="entry", + forecast_days=1, + create_d1=False, + create_d2=False, + ) + + try: + with pytest.raises(sensor.UpdateFailed, match="net down"): + loop.run_until_complete(coordinator._async_update_data()) + finally: + loop.close() + + assert session.calls == 2 + assert delays == [0.8] + + def test_async_setup_entry_missing_api_key_triggers_reauth() -> None: """A missing API key results in ConfigEntryAuthFailed during setup.""" diff --git a/tests/test_translations.py b/tests/test_translations.py new file mode 100644 index 00000000..6cc8684c --- /dev/null +++ b/tests/test_translations.py @@ -0,0 +1,401 @@ +"""Translation coverage tests for the Pollen Levels integration. + +These tests parse ``config_flow.py`` with a simple AST walker to ensure every +translation key used in the config/options flows exists in each locale file. +If the flow code changes structure, update the helper below rather than +changing the assertions to keep the guarantees intact. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path +from typing import Any + +import pytest + +# 🔧 Adjust this per repository +INTEGRATION_DOMAIN = "pollenlevels" + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +COMPONENT_DIR = PROJECT_ROOT / "custom_components" / INTEGRATION_DOMAIN +TRANSLATIONS_DIR = COMPONENT_DIR / "translations" +CONFIG_FLOW_PATH = COMPONENT_DIR / "config_flow.py" +CONST_PATH = COMPONENT_DIR / "const.py" + + +def _fail_unexpected_ast(context: str) -> None: + """Fail with a consistent, actionable message for unsupported AST shapes.""" + + pytest.fail( + "Unexpected AST layout while extracting translation keys from " + f"config_flow.py ({context}); please update the helper in test_translations.py", + ) + + +def _flatten_keys(data: dict[str, Any], prefix: str = "") -> set[str]: + """Flatten nested dict keys into dotted paths.""" + + keys: set[str] = set() + for key, value in data.items(): + path = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + keys.update(_flatten_keys(value, path)) + else: + keys.add(path) + return keys + + +def _load_translation(path: Path) -> dict[str, Any]: + """Load a translation JSON file.""" + + if not path.is_file(): + raise AssertionError(f"Missing translation file: {path}") + with path.open("r", encoding="utf-8") as file: + return json.load(file) + + +def test_translations_match_english_keyset() -> None: + """Verify all locale files mirror the English translation keyset.""" + + en_path = TRANSLATIONS_DIR / "en.json" + english_keys = _flatten_keys(_load_translation(en_path)) + + problems: list[str] = [] + for translation_path in TRANSLATIONS_DIR.glob("*.json"): + if translation_path.name == "en.json": + continue + locale_keys = _flatten_keys(_load_translation(translation_path)) + missing = english_keys - locale_keys + extra = locale_keys - english_keys + if missing or extra: + problems.append( + f"{translation_path.name}: " + f"missing {sorted(missing)} extra {sorted(extra)}" + ) + assert not problems, "Translation keys mismatch: " + "; ".join(problems) + + +def test_config_flow_translation_keys_present() -> None: + """Ensure config/options flow keys referenced in code exist in English JSON.""" + + english = _flatten_keys(_load_translation(TRANSLATIONS_DIR / "en.json")) + flow_keys = _extract_config_flow_keys() + missing = flow_keys - english + assert not missing, f"Missing config_flow translation keys: {sorted(missing)}" + + +def _extract_constant_assignments(tree: ast.AST) -> dict[str, str]: + """Collect string literal assignments from an AST. + + Only handles simple cases like: + NAME = "literal" + """ + + constants: dict[str, str] = {} + for node in ast.walk(tree): + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + + target = None + if isinstance(node, ast.Assign): + if len(node.targets) == 1: + target = node.targets[0] + else: # AnnAssign + target = node.target + + value = node.value + if ( + isinstance(target, ast.Name) + and value is not None + and isinstance(value, ast.Constant) + and isinstance(value.value, str) + ): + constants[target.id] = value.value + return constants + + +def _resolve_name(name: str, mapping: dict[str, str]) -> str | None: + """Resolve a variable name to its string value if known.""" + + return mapping.get(name) + + +def _fields_from_schema_call(call: ast.Call, mapping: dict[str, str]) -> set[str]: + """Extract field keys from a vol.Schema(...) call. + + Looks for patterns like: + vol.Schema({vol.Required(CONF_USERNAME): str, ...}) + """ + + if not call.args or not isinstance(call.args[0], ast.Dict): + _fail_unexpected_ast("schema call arguments") + arg = call.args[0] + + fields: set[str] = set() + for key in arg.keys: + if not isinstance(key, ast.Call) or not isinstance(key.func, ast.Attribute): + _fail_unexpected_ast("schema key wrapper") + if key.func.attr not in {"Required", "Optional"}: + _fail_unexpected_ast(f"unexpected schema call {key.func.attr}") + if not key.args: + _fail_unexpected_ast("schema key args") + selector = key.args[0] + if isinstance(selector, ast.Constant) and isinstance(selector.value, str): + fields.add(selector.value) + elif isinstance(selector, ast.Name): + resolved = _resolve_name(selector.id, mapping) + if resolved: + fields.add(resolved) + else: + _fail_unexpected_ast(f"unmapped selector {selector.id}") + else: + _fail_unexpected_ast("selector type") + return fields + + +def _extract_schema_fields( + tree: ast.AST, mapping: dict[str, str] +) -> dict[str, set[str]]: + """Map schema helper names to their field keys. + + Collects: + - Functions like _user_schema / _options_schema returning vol.Schema(...) + - Top-level assignments like USER_SCHEMA = vol.Schema(...) + """ + + fields: dict[str, set[str]] = {} + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name in { + "_user_schema", + "_options_schema", + }: + returns = [ + child for child in ast.walk(node) if isinstance(child, ast.Return) + ] + for ret in returns: + if isinstance(ret.value, ast.Call): + fields.setdefault(node.name, set()).update( + _fields_from_schema_call(ret.value, mapping) + ) + if isinstance(node, ast.Assign): + if ( + isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == "Schema" + ): + name = node.targets[0].id + fields.setdefault(name, set()).update( + _fields_from_schema_call(node.value, mapping) + ) + return fields + + +def _is_options_flow_class(name: str) -> bool: + """Heuristic to decide if a class represents an options flow. + + Works for names like: + AirzoneOptionsFlow + PollenLevelsOptionsFlowHandler + MyIntegrationOptionsFlow + """ + + lower = name.lower() + return "optionsflow" in lower or "options_flow" in lower + + +def _extract_config_flow_keys() -> set[str]: + """Parse config_flow.py to derive translation keys used in flows. + + This covers: + - config.step..title + - config.step..description + - config.step..data. + - config.error. + - config.abort. + And the equivalent options.* keys for options flows. + """ + + if not CONFIG_FLOW_PATH.is_file(): + raise AssertionError(f"Missing config_flow.py at {CONFIG_FLOW_PATH}") + + config_tree = ast.parse(CONFIG_FLOW_PATH.read_text(encoding="utf-8")) + const_tree: ast.AST | None = None + if CONST_PATH.is_file(): + const_tree = ast.parse(CONST_PATH.read_text(encoding="utf-8")) + + manual_mapping: dict[str, str] = { + "CONF_USERNAME": "username", + "CONF_PASSWORD": "password", + "CONF_API_KEY": "api_key", + "CONF_LATITUDE": "latitude", + "CONF_LONGITUDE": "longitude", + "CONF_LANGUAGE": "language", + "CONF_SCAN_INTERVAL": "scan_interval", + } + + mapping: dict[str, str] = dict(manual_mapping) + if const_tree is not None: + mapping.update(_extract_constant_assignments(const_tree)) + mapping.update(_extract_constant_assignments(config_tree)) + + schema_fields = _extract_schema_fields(config_tree, mapping) + + language_error_returns: set[str] = set() + + class _LanguageErrorVisitor(ast.NodeVisitor): + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: # noqa: N802 + if node.name != "_language_error_to_form_key": + return + for child in ast.walk(node): + if ( + isinstance(child, ast.Return) + and isinstance(child.value, ast.Constant) + and isinstance(child.value.value, str) + ): + language_error_returns.add(child.value.value) + + _LanguageErrorVisitor().visit(config_tree) + + def _extract_error_values(value: ast.AST) -> set[str]: + values: set[str] = set() + if isinstance(value, ast.Constant) and isinstance(value.value, str): + values.add(value.value) + elif ( + isinstance(value, ast.Call) + and isinstance(value.func, ast.Name) + and value.func.id == "_language_error_to_form_key" + ): + values.update(language_error_returns) + return values + + error_keys_from_assignments: set[str] = set() + + class _ErrorsVisitor(ast.NodeVisitor): + def visit_Assign(self, node: ast.Assign) -> None: # noqa: N802 + for target in node.targets: + self._record_errors(target, node.value) + self.generic_visit(node) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: # noqa: N802 + self._record_errors(node.target, node.value) + self.generic_visit(node) + + def _record_errors(self, target: ast.AST, value: ast.AST | None) -> None: + if ( + isinstance(target, ast.Subscript) + and isinstance(target.value, ast.Name) + and target.value.id == "errors" + and value is not None + ): + error_keys_from_assignments.update(_extract_error_values(value)) + + _ErrorsVisitor().visit(config_tree) + + keys: set[str] = set() + + class FlowVisitor(ast.NodeVisitor): + def __init__(self) -> None: + self.class_stack: list[str] = [] + self.local_schema_vars: dict[str, set[str]] = dict(schema_fields) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: # noqa: N802 + self.class_stack.append(node.name) + self.generic_visit(node) + self.class_stack.pop() + + def visit_Assign(self, node: ast.Assign) -> None: # noqa: N802 + if ( + isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Attribute) + and node.value.func.attr == "Schema" + ): + name = node.targets[0].id + self.local_schema_vars[name] = _fields_from_schema_call( + node.value, mapping + ) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: # noqa: N802 + prefix = "config" + if self.class_stack and _is_options_flow_class(self.class_stack[-1]): + prefix = "options" + + func_attr: str | None = None + if isinstance(node.func, ast.Attribute): + func_attr = node.func.attr + + if func_attr == "async_show_form": + self._handle_show_form(node, prefix) + if func_attr == "async_abort": + self._handle_abort(node, prefix) + + self.generic_visit(node) + + def _handle_show_form(self, node: ast.Call, prefix: str) -> None: + step_id: str | None = None + schema_name: str | None = None + inline_schema_fields: set[str] = set() + + for kw in node.keywords: + if kw.arg == "step_id" and isinstance(kw.value, ast.Constant): + step_id = str(kw.value.value) + if kw.arg == "data_schema": + if isinstance(kw.value, ast.Name): + schema_name = kw.value.id + elif isinstance(kw.value, ast.Call): + if ( + isinstance(kw.value.func, ast.Attribute) + and kw.value.func.attr == "Schema" + ): + inline_schema_fields.update( + _fields_from_schema_call(kw.value, mapping) + ) + if kw.arg == "errors": + if isinstance(kw.value, ast.Dict): + for err_value in kw.value.values: + err_key: str | None = None + if isinstance(err_value, ast.Constant) and isinstance( + err_value.value, str + ): + err_key = err_value.value + elif isinstance(err_value, ast.Name): + err_key = _resolve_name(err_value.id, mapping) + if err_key: + keys.add(f"{prefix}.error.{err_key}") + elif isinstance(kw.value, ast.Name): + if kw.value.id == "errors": + for err_key in error_keys_from_assignments: + keys.add(f"{prefix}.error.{err_key}") + else: + resolved = _resolve_name(kw.value.id, mapping) + if resolved: + keys.add(f"{prefix}.error.{resolved}") + + if not step_id: + return + + keys.add(f"{prefix}.step.{step_id}.title") + keys.add(f"{prefix}.step.{step_id}.description") + + if schema_name and schema_name in self.local_schema_vars: + for field in self.local_schema_vars[schema_name]: + keys.add(f"{prefix}.step.{step_id}.data.{field}") + + for field in inline_schema_fields: + keys.add(f"{prefix}.step.{step_id}.data.{field}") + + def _handle_abort(self, node: ast.Call, prefix: str) -> None: + for kw in node.keywords: + if ( + kw.arg == "reason" + and isinstance(kw.value, ast.Constant) + and isinstance(kw.value.value, str) + ): + keys.add(f"{prefix}.abort.{kw.value.value}") + + FlowVisitor().visit(config_tree) + return keys