From 40fff3e75f8cd68a9099788e8576f535a474b1c9 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Wed, 20 May 2026 15:19:48 +1000 Subject: [PATCH] feat(providers): add OpenElectricity wholesale-price client (Phase 7 PR-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone async client for OpenElectricity v4 — not yet wired into the coordinator or config flow (that's PR-2 part 2 / Plan 07-02b). Foundation for Phase 8 Silver-compliance handlers and Phase 9 external-statistics integration. * ``custom_components/pricehawk/providers/openelectricity.py`` — new module exposing ``OpenElectricityPriceSource`` async client + ``WholesalePrice`` frozen dataclass. CC BY-NC 4.0 attribution baked into every result per research §1.3. * ``manifest.json:requirements`` pinned to ``openelectricity>=0.10.1,<0.11`` — minor-bounded per the SDK README's "currently under active development" notice. Audit invariants applied (07-02-AUDIT.md): * M1 — API key never appears in ``__repr__`` output or log messages. ``_scrub()`` filters the full key plus its 8-char prefix from any text passed to ``_LOGGER.warning``. ``test_log_scrubs_api_key_from_sdk_error`` uses ``caplog`` to verify against a leaky SDK exception. * M2 — ``asyncio.timeout(_FETCH_TIMEOUT_SECONDS=30.0)`` bounds every SDK call. Hung connection → ``None`` + WARNING, never an HA-wide stall. * M3 — Missing ``openelectricity`` install raises ``ConfigEntryNotReady`` (HA retries setup), not ``ImportError`` (permanent crash). Lazy import inside the async method. * S1 — Auth-error detection is belt-and-braces: message-string match plus exception-class-name probe. Defensive against the SDK's active-development error-message format drift. * S2 — HTTP 429 rate-limit has a distinct branch: ``None`` + WARNING saying "rate-limited", last-good cache preserved, no spurious ``ConfigEntryAuthFailed``. * S5 — Module is intentionally NOT exported from ``providers/__init__.py``. ``OpenElectricityPriceSource`` is a wholesale-price source, not a ``Provider`` Protocol implementation. Consumers import directly. Implementation diverged from plan pseudo-code: real ``openelectricity==0.10.1`` ``TimeSeriesResponse`` is nested three levels deep (``data`` → ``results`` → ``data``), not flat. ``_extract_latest_for_region`` walks the nested structure and returns tz-aware UTC. We skip ``response.to_records()`` because it emits naive-local timestamps. 12 contract tests (``tests/test_openelectricity_provider.py``) cover AC-1 through AC-7e: happy-path NEM, happy-path WEM, attribution-verbatim, 401 mapping, generic-error cache preservation, empty-data, invalid region, empty API key, timeout, missing SDK, 429 rate limit, repr redaction, log scrub. Test suite: 815 → 827 passed. ``tests/conftest.py`` gained one stub: ``ConfigEntryAuthFailed`` (was only ``ConfigEntryNotReady`` before). Necessary because the new module is the first user of the auth-failed exception in this project. Decisions D-P7-5 through D-P7-8 logged to DECISIONS.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + DECISIONS.md | 26 ++ custom_components/pricehawk/manifest.json | 2 +- .../pricehawk/providers/openelectricity.py | 275 ++++++++++++ tests/conftest.py | 4 + tests/test_openelectricity_provider.py | 399 ++++++++++++++++++ 6 files changed, 709 insertions(+), 1 deletion(-) create mode 100644 custom_components/pricehawk/providers/openelectricity.py create mode 100644 tests/test_openelectricity_provider.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db939d..eb8c1bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- OpenElectricity v4 wholesale-price client module at `custom_components/pricehawk/providers/openelectricity.py`. Standalone — not yet wired into the coordinator or config flow; that's PR-2 part 2 (Plan 07-02b). Pinned `openelectricity>=0.10.1,<0.11` in manifest. Includes CC BY-NC 4.0 attribution on every result, 30s `asyncio.timeout` bound, `ConfigEntryAuthFailed` mapping for 401, distinct 429 rate-limit handling that preserves the last-good cache, and `ConfigEntryNotReady` fallback for missing-SDK installs. API key never appears in `__repr__` or log messages (scrubber). (Phase 7 / PR-2) + ### Changed - Internal: typed runtime data via `PriceHawkData` dataclass + `PriceHawkConfigEntry` alias; `entry.runtime_data` replaces `hass.data[DOMAIN][entry_id]` for coordinator storage. Service handlers (`analyze_csv`, `backfill_history`, `rank_alternatives`) re-resolve the coordinator on every invocation, eliminating a stale-closure bug latent across `OptionsFlowWithReload` cycles. Unload re-ordered: `async_unload_platforms` runs first, coordinator teardown only on success. Multi-entry service deregistration now sourced from `hass.config_entries.async_entries(DOMAIN)`. No user-facing change. (Phase 7 / PR-1) diff --git a/DECISIONS.md b/DECISIONS.md index d91aaa7..8f2def5 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -5,6 +5,32 @@ +## 2026-05-20 — Phase 7 Plan 02 (OpenElectricity wholesale-price client) + +### D-P7-5 — Adopt `openelectricity` SDK (>=0.10.1,<0.11) as the primary wholesale-price source +**Decision:** Pin `openelectricity>=0.10.1,<0.11` in `manifest.json:requirements`. Introduce `custom_components/pricehawk/providers/openelectricity.py` exposing `OpenElectricityPriceSource` async client + `WholesalePrice` frozen dataclass. Standalone module — no coordinator/config-flow wiring in this PR (deferred to 07-02b). +**Rationale:** PR-2 from `PriceHawk v2 — Deep Research Round 2 (Scope-Corrected).md` (Wave 1). OpenElectricity v4 is the right primary wholesale-price source per research §1.1–1.5: official Python SDK (`AsyncOEClient`), 5-minute interval, JSON envelope, CC BY-NC 4.0 license keeps PriceHawk non-commercial. Minor-bounded pin per research §1.4 ("currently under active development"). +**Alternatives:** Stay on `aemo_api.py` (NEMWeb-only, no WEM, brittle CSV-in-ZIP, HTTP deprecation 2026-04-07). OpenNEM v3 (deprecated). jxeeno community endpoints (no SLA). +**Consequences:** New external dependency; HA wheel resolver picks it up. 07-02b wires into coordinator. 07-03 keeps NEMWeb as no-API-key fallback. CC BY-NC attribution surfacing becomes a cross-PR contract (see D-P7-7). + +### D-P7-6 — Lazy SDK import + ConfigEntryNotReady on ImportError +**Decision:** External SDK imports (`from openelectricity import AsyncOEClient`) live INSIDE the async fetch method, wrapped in `try/except ImportError` that re-raises as `homeassistant.exceptions.ConfigEntryNotReady`. NOT at module top. +**Rationale:** Module-top imports of HACS-resolved dependencies have two failure modes: (a) test environments mocking the SDK via `sys.modules` would crash at conftest collection, and (b) a partial HACS install crashes the integration module with a hard `ImportError` that HA does NOT recognise as a retry signal — permanent error state until HA restart. `ConfigEntryNotReady` is HA's "try again later" exception with exponential backoff. +**Alternatives:** Module-top import (rejected). Module-top with `_HAS_SDK` flag (pollutes every call site). +**Consequences:** Reusable pattern for any future external-SDK provider. Phase 7/8 PRs introducing new SDKs MUST follow this pattern. Slight per-call overhead vs single import but production-correct. + +### D-P7-7 — API key handling: `__repr__` redaction + `_scrub()` log filter (CWE-532 stance) +**Decision:** Any class that owns an API key MUST: (a) override `__repr__` so the key never appears in repr output (use `` marker); (b) provide a `_scrub(text)` helper that replaces the full key AND any 8+ char prefix with redaction markers, and call `_scrub()` on EVERY string passed to `_LOGGER.warning/info/debug` that originated from an external dependency. Tests MUST include a `caplog`-based assertion that a leaky SDK exception (one whose `str()` contains the API key) does NOT leak through the integration's log output. +**Rationale:** CWE-532 (Information Exposure Through Log Files) is a baseline SOC-2 concern. HTTP-client libraries commonly include request URLs, headers, or token fragments in exception messages. Passing raw SDK exceptions to `_LOGGER` leaks the key to `home-assistant.log` and downstream log streams. Audit Gap M1 on 07-02-AUDIT.md. +**Alternatives:** Trust the SDK (SDKs change). HA's `async_redact_data` (that's for diagnostics platform output, not arbitrary log lines). Global log-filter regex (would need to know the key format ahead of time). +**Consequences:** Pattern is template-able for existing Amber/LocalVolts/Flow Power providers (separate follow-up — their log paths not yet audited). Future external-API integrations MUST follow as part of their PR. ~10 lines per provider; saves a CWE-532 finding per provider. + +### D-P7-8 — Every external-SDK call MUST be bounded by `asyncio.timeout(N)` +**Decision:** Wrap every `await sdk_client.method(...)` in `async with asyncio.timeout(_TIMEOUT_SECONDS):`. Define `_TIMEOUT_SECONDS` as a module-level `Final[float]` constant per provider (default 30.0 for HTTP-backed SDKs). On `asyncio.TimeoutError` return None + WARNING log naming the timeout. +**Rationale:** HA's DataUpdateCoordinator eventually times out the whole tick on a hung provider, but the diagnostic signal is misleading (looks like a coordinator bug, not a provider hang). Bounding at the provider level produces a specific WARNING log line. SDK internal defaults are undocumented and can drift between minor versions. Audit Gap M2 on 07-02-AUDIT.md. +**Alternatives:** Trust SDK default (undocumented + drift risk). HA coordinator timeout (wrong diagnostic signal). `aiohttp.ClientTimeout` (SDK doesn't expose the session). +**Consequences:** Every external-SDK call site includes an `asyncio.timeout` wrapper. Hung connection → None result + WARNING, not HA-wide stall. Existing `aemo_api.py` uses custom retry logic — bringing under same pattern is a future cleanup. + ## 2026-05-20 — Phase 7 Plan 01 (typed runtime data) ### D-P7-1 — Adopt `PriceHawkConfigEntry = ConfigEntry[PriceHawkData]` typed-entry alias diff --git a/custom_components/pricehawk/manifest.json b/custom_components/pricehawk/manifest.json index e62b8d7..5ec37b9 100644 --- a/custom_components/pricehawk/manifest.json +++ b/custom_components/pricehawk/manifest.json @@ -9,6 +9,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/Artic0din/ha-pricehawk/issues", - "requirements": [], + "requirements": ["openelectricity>=0.10.1,<0.11"], "version": "1.5.0-beta.2" } diff --git a/custom_components/pricehawk/providers/openelectricity.py b/custom_components/pricehawk/providers/openelectricity.py new file mode 100644 index 0000000..9e20be4 --- /dev/null +++ b/custom_components/pricehawk/providers/openelectricity.py @@ -0,0 +1,275 @@ +"""OpenElectricity v4 wholesale-price client for PriceHawk (Phase 7 / PR-2). + +Standalone module — not yet wired into the coordinator or config flow; that's +Plan 07-02b. The openelectricity SDK is imported lazily inside +``fetch_current_price`` so a missing-SDK install raises ``ConfigEntryNotReady`` +(HA retries setup) rather than crashing module import. + +Audit invariants (07-02-AUDIT.md): +- API key NEVER appears in __repr__ output or log messages. +- SDK call bounded by _FETCH_TIMEOUT_SECONDS via asyncio.timeout. +- Missing SDK at runtime → ConfigEntryNotReady, not ImportError. +- 401-equivalent → ConfigEntryAuthFailed (caller must re-prompt for key). +- 429-equivalent → return None + WARNING (does NOT raise AuthFailed). +- Other errors → return None + WARNING; last-good cache preserved. +""" + +from __future__ import annotations + +import asyncio +import logging +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Final, TypeAlias + +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +_LOGGER = logging.getLogger(__name__) + +_ATTRIBUTION: Final[str] = ( + "Wholesale price data: Open Electricity (Superpower Institute), CC BY-NC 4.0" +) + +# Region → network_code mapping (research §1.2). The SDK accepts "NEM", "WEM", "AU". +_NEM_REGIONS: Final[frozenset[str]] = frozenset({"NSW1", "QLD1", "SA1", "TAS1", "VIC1"}) +_WEM_REGIONS: Final[frozenset[str]] = frozenset({"WEM"}) + +# Audit M2: bounded SDK call. 30s is generous for a 5-min cadence; HA polling +# tolerates slow ticks but not hung ones. +_FETCH_TIMEOUT_SECONDS: Final[float] = 30.0 + +_REDACTED: Final[str] = "" +_REDACTED_PREFIX: Final[str] = "" + + +@dataclass(slots=True, frozen=True) +class WholesalePrice: + """Result type for fetch_current_price(). + + IMPORTANT — do NOT add fields without coordinated update of consumers + in 07-02b and any subsequent PRs. frozen=True is part of the cross-PR + contract (07-02-PLAN.md > CROSS-PR CONTRACTS). + """ + + price_aud_per_mwh: float + interval_end_utc: datetime + region: str + attribution: str = _ATTRIBUTION + + +_LatestPoint: TypeAlias = tuple[float, datetime] + + +class OpenElectricityPriceSource: + """Async client for OpenElectricity v4 wholesale-price queries. + + Owns auth (API key passed at construction, never logged), region→network_code + mapping, last-good cache, and error→exception mapping. Does NOT own polling + cadence — the caller decides when to invoke fetch_current_price. + """ + + def __init__(self, api_key: str) -> None: + if not api_key: + raise ValueError("api_key must be a non-empty string") + self._api_key = api_key + self._last_good_by_region: dict[str, WholesalePrice] = {} + + def __repr__(self) -> str: + # Audit M1: API key MUST NOT appear in repr. + cached_regions = sorted(self._last_good_by_region.keys()) + return ( + f"OpenElectricityPriceSource(api_key={_REDACTED}, " + f"cached_regions={cached_regions!r})" + ) + + @staticmethod + def _network_code_for(region: str) -> str: + if region in _NEM_REGIONS: + return "NEM" + if region in _WEM_REGIONS: + return "WEM" + raise ValueError( + f"Unknown OpenElectricity region {region!r}; " + f"expected one of {sorted(_NEM_REGIONS | _WEM_REGIONS)}" + ) + + def _scrub(self, text: str) -> str: + """Redact the API key from any string before logging it (audit M1).""" + if not text: + return text + scrubbed = text.replace(self._api_key, _REDACTED) + if len(self._api_key) >= 8: + scrubbed = scrubbed.replace(self._api_key[:8], _REDACTED_PREFIX) + return scrubbed + + async def fetch_current_price(self, region: str) -> WholesalePrice | None: + """Fetch the latest 5-minute dispatch price for the region. + + Returns None on non-auth, non-rate-limit errors and empty-data responses; + caller can fall back to last-good via ``last_good(region)``. + + Raises: + ValueError: unknown region (caller bug — raised before any network call). + ConfigEntryAuthFailed: HTTP 401 / auth-equivalent — API key needs renewal. + ConfigEntryNotReady: openelectricity SDK is not importable at runtime. + """ + # Validate region BEFORE any network call. + network_code = self._network_code_for(region) + + # Audit M3: lazy import + ImportError → ConfigEntryNotReady. + try: + from openelectricity import AsyncOEClient # noqa: PLC0415 + from openelectricity.types import MarketMetric # noqa: PLC0415 + except ImportError as exc: + raise ConfigEntryNotReady( + "openelectricity SDK is not installed. The HA wheel resolver " + "should pick it up from manifest.json:requirements; if this " + "persists, install manually: " + "pip install 'openelectricity>=0.10.1,<0.11'. " + f"({exc})" + ) from exc + + # Audit M2: bounded SDK call. + try: + async with asyncio.timeout(_FETCH_TIMEOUT_SECONDS): + async with AsyncOEClient(api_key=self._api_key) as client: + response = await client.get_market( + network_code=network_code, # type: ignore[arg-type] + metrics=[MarketMetric.PRICE], + interval="5m", + primary_grouping="network_region", + ) + except asyncio.TimeoutError: + _LOGGER.warning( + "OpenElectricity fetch timed out after %.0fs for region %s", + _FETCH_TIMEOUT_SECONDS, + region, + ) + return None + except Exception as exc: # noqa: BLE001 — classified below + if _is_auth_error(exc): + raise ConfigEntryAuthFailed( + f"OpenElectricity API rejected the key (HTTP 401): " + f"{self._scrub(str(exc))}" + ) from exc + if _is_rate_limit_error(exc): + _LOGGER.warning( + "OpenElectricity rate-limited fetch for region %s; " + "preserving cached last-good. Detail: %s", + region, + self._scrub(str(exc)), + ) + return None + _LOGGER.warning( + "OpenElectricity fetch failed for %s: %s", + region, + self._scrub(str(exc)), + ) + return None + + extracted = _extract_latest_for_region(response, region) + if extracted is None: + _LOGGER.warning( + "OpenElectricity returned no data for region %s " + "(response had %d point(s))", + region, + _record_count(response), + ) + return None + + price_value, ts_utc = extracted + price = WholesalePrice( + price_aud_per_mwh=price_value, + interval_end_utc=ts_utc, + region=region, + ) + self._last_good_by_region[region] = price + return price + + def last_good(self, region: str) -> WholesalePrice | None: + """Return the cached last successful fetch for the region, if any.""" + return self._last_good_by_region.get(region) + + +def _is_auth_error(exc: Exception) -> bool: + """Detect 401-equivalent errors. Belt-and-braces: message + class name (audit S1).""" + msg = str(exc).lower() + if ( + "401" in msg + or "unauthor" in msg + or "invalid api key" in msg + or "forbidden" in msg + ): + return True + class_name = type(exc).__name__.lower() + return any( + token in class_name + for token in ("auth", "unauthor", "forbidden", "credential") + ) + + +def _is_rate_limit_error(exc: Exception) -> bool: + """Detect 429-equivalent errors (audit S2).""" + msg = str(exc).lower() + if ( + "429" in msg + or "rate limit" in msg + or "rate-limit" in msg + or "too many requests" in msg + ): + return True + class_name = type(exc).__name__.lower() + return "ratelimit" in class_name or "throttle" in class_name + + +def _extract_latest_for_region( + response: object, region: str +) -> _LatestPoint | None: + """Walk the nested TimeSeriesResponse and pull the latest (price, ts) for `region`. + + Verified against openelectricity==0.10.1 (2026-05-20). Real shape: + response.data: Sequence[NetworkTimeSeries] + NetworkTimeSeries.results: list[TimeSeriesResult] + TimeSeriesResult.columns.network_region: str + TimeSeriesResult.data: list[] + + Does NOT use response.to_records() — that flattens but emits naive-local + timestamps; we need tz-aware UTC. + """ + data = getattr(response, "data", None) + if not data: + return None + + candidates: list[_LatestPoint] = [] + for series in data: + for result in getattr(series, "results", []): + columns = getattr(result, "columns", None) + row_region = ( + getattr(columns, "network_region", None) if columns else None + ) + if row_region != region: + continue + for point in getattr(result, "data", []): + ts = getattr(point, "timestamp", None) + val = getattr(point, "value", None) + if ts is None or val is None: + continue + if ts.tzinfo is None: + ts = ts.replace(tzinfo=timezone.utc) + candidates.append((float(val), ts)) + + if not candidates: + return None + return max(candidates, key=lambda pair: pair[1]) + + +def _record_count(response: object) -> int: + """Helper for log messages — total data points across all series/results.""" + data = getattr(response, "data", None) + if not data: + return 0 + return sum( + len(getattr(result, "data", [])) + for series in data + for result in getattr(series, "results", []) + ) diff --git a/tests/conftest.py b/tests/conftest.py index c1e07bf..f39e50d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,10 @@ def __init__(self, *args, **kwargs): _mods["homeassistant.exceptions"].ConfigEntryNotReady = type( "ConfigEntryNotReady", (Exception,), {} ) +# Phase 7 PR-2: ConfigEntryAuthFailed for OpenElectricity 401 mapping +_mods["homeassistant.exceptions"].ConfigEntryAuthFailed = type( + "ConfigEntryAuthFailed", (Exception,), {} +) _mods["homeassistant"].exceptions = _mods["homeassistant.exceptions"] for name, mod in _mods.items(): diff --git a/tests/test_openelectricity_provider.py b/tests/test_openelectricity_provider.py new file mode 100644 index 0000000..f16b296 --- /dev/null +++ b/tests/test_openelectricity_provider.py @@ -0,0 +1,399 @@ +"""Contract tests for OpenElectricityPriceSource (Phase 7 / PR-2). + +Covers AC-1b, AC-2 through AC-7e per ``07-02-PLAN.md``. The openelectricity SDK +is mocked via sys.modules — no real network calls, no SDK install required. + +Async pattern matches the rest of the suite: ``asyncio.run(...)`` inside sync +tests (no pytest-asyncio dependency). +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +from datetime import datetime, timezone +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# SDK stubs — install BEFORE importing the module under test so the lazy +# `from openelectricity import AsyncOEClient` inside fetch_current_price +# resolves to our stubs at test time. +# --------------------------------------------------------------------------- + + +def _install_sdk_stubs() -> None: + """Idempotently install openelectricity stubs into sys.modules.""" + if "openelectricity" not in sys.modules: + sys.modules["openelectricity"] = MagicMock() + if "openelectricity.types" not in sys.modules: + types_mod = MagicMock() + # MarketMetric.PRICE just needs to be a truthy sentinel. + types_mod.MarketMetric = MagicMock(PRICE="price") + sys.modules["openelectricity.types"] = types_mod + + +_install_sdk_stubs() + + +# Defer module import until after stubs are in place. +from custom_components.pricehawk.providers.openelectricity import ( # noqa: E402 + _ATTRIBUTION, + OpenElectricityPriceSource, + WholesalePrice, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _build_response( + region: str = "NSW1", + price: float = 85.42, + ts: datetime | None = None, +) -> SimpleNamespace: + """Build a stub TimeSeriesResponse matching openelectricity==0.10.1 shape. + + Real shape (verified via venv introspection 2026-05-20): + response.data: Sequence[NetworkTimeSeries] + NetworkTimeSeries.results: list[TimeSeriesResult] + TimeSeriesResult.columns.network_region: str + TimeSeriesResult.data: list[] + """ + if ts is None: + ts = datetime(2026, 5, 20, 1, 30, 0, tzinfo=timezone.utc) + point = SimpleNamespace(timestamp=ts, value=price) + columns = SimpleNamespace(network_region=region) + result = SimpleNamespace(columns=columns, data=[point]) + series = SimpleNamespace(results=[result]) + return SimpleNamespace(data=[series]) + + +def _mock_async_client(get_market_result: Any) -> MagicMock: + """Build an AsyncOEClient factory mock supporting `async with`. + + Returns a callable that, when invoked (i.e. `AsyncOEClient(api_key=...)`), + yields an object with `__aenter__`/`__aexit__` and `.get_market`. + """ + client = MagicMock() + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=None) + if isinstance(get_market_result, Exception): + client.get_market = AsyncMock(side_effect=get_market_result) + elif callable(get_market_result) and not isinstance( + get_market_result, AsyncMock + ): + client.get_market = AsyncMock(side_effect=get_market_result) + else: + client.get_market = AsyncMock(return_value=get_market_result) + return MagicMock(return_value=client) + + +def _patch_sdk(client_factory: MagicMock): + """Patch the SDK symbols the module imports lazily.""" + return patch.dict( + sys.modules, + { + "openelectricity": MagicMock(AsyncOEClient=client_factory), + "openelectricity.types": MagicMock( + MarketMetric=MagicMock(PRICE="price") + ), + }, + ) + + +# --------------------------------------------------------------------------- +# AC-1b / AC-2: happy-path NEM region + attribution +# --------------------------------------------------------------------------- + + +def test_happy_path_nem_region_returns_wholesale_price_with_attribution(): + factory = _mock_async_client(_build_response("NSW1", price=85.42)) + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + + with _patch_sdk(factory): + result = asyncio.run(src.fetch_current_price("NSW1")) + + assert isinstance(result, WholesalePrice) + assert result.price_aud_per_mwh == 85.42 + assert result.region == "NSW1" + assert result.interval_end_utc == datetime( + 2026, 5, 20, 1, 30, 0, tzinfo=timezone.utc + ) + # AC-1b: verbatim attribution string (research §1.3). + assert result.attribution == ( + "Wholesale price data: Open Electricity (Superpower Institute), " + "CC BY-NC 4.0" + ) + assert _ATTRIBUTION == result.attribution + + # Validate SDK was called with the right params. + client_instance = factory.return_value + client_instance.get_market.assert_awaited_once() + kwargs = client_instance.get_market.await_args.kwargs + assert kwargs["network_code"] == "NEM" + assert kwargs["interval"] == "5m" + assert kwargs["primary_grouping"] == "network_region" + + +# --------------------------------------------------------------------------- +# AC-3: WEM region resolves to network_code="WEM" +# --------------------------------------------------------------------------- + + +def test_happy_path_wem_region_uses_wem_network_code(): + factory = _mock_async_client(_build_response("WEM", price=72.10)) + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + + with _patch_sdk(factory): + result = asyncio.run(src.fetch_current_price("WEM")) + + assert result is not None + assert result.region == "WEM" + kwargs = factory.return_value.get_market.await_args.kwargs + assert kwargs["network_code"] == "WEM" + + +# --------------------------------------------------------------------------- +# AC-4: 401 → ConfigEntryAuthFailed +# --------------------------------------------------------------------------- + + +def test_401_maps_to_config_entry_auth_failed(): + from homeassistant.exceptions import ConfigEntryAuthFailed + + factory = _mock_async_client(RuntimeError("HTTP 401 Unauthorized")) + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + + with _patch_sdk(factory): + with pytest.raises(ConfigEntryAuthFailed) as exc_info: + asyncio.run(src.fetch_current_price("VIC1")) + + assert "OpenElectricity" in str(exc_info.value) + + +# --------------------------------------------------------------------------- +# AC-5: non-auth/non-rate-limit errors → None, cache preserved +# --------------------------------------------------------------------------- + + +def test_non_auth_error_returns_none_and_preserves_cache(): + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + factory_ok = _mock_async_client(_build_response("NSW1", price=85.42)) + factory_fail = _mock_async_client(RuntimeError("connection refused")) + factory_ok2 = _mock_async_client(_build_response("NSW1", price=92.50)) + + with _patch_sdk(factory_ok): + first = asyncio.run(src.fetch_current_price("NSW1")) + assert first is not None + assert src.last_good("NSW1") is first + + with _patch_sdk(factory_fail): + second = asyncio.run(src.fetch_current_price("NSW1")) + assert second is None + # Cache survives failure. + assert src.last_good("NSW1") is first + + with _patch_sdk(factory_ok2): + third = asyncio.run(src.fetch_current_price("NSW1")) + assert third is not None + assert third.price_aud_per_mwh == 92.50 + # Cache updated on success. + assert src.last_good("NSW1") is third + + +# --------------------------------------------------------------------------- +# AC-6: empty-data response → None + WARNING +# --------------------------------------------------------------------------- + + +def test_empty_data_returns_none_and_warns(caplog: pytest.LogCaptureFixture): + # response.data is empty list — no series, no results. + empty_response = SimpleNamespace(data=[]) + factory = _mock_async_client(empty_response) + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + + with _patch_sdk(factory), caplog.at_level( + logging.WARNING, + logger="custom_components.pricehawk.providers.openelectricity", + ): + result = asyncio.run(src.fetch_current_price("NSW1")) + + assert result is None + warning_records = [r for r in caplog.records if r.levelname == "WARNING"] + assert any("no data for region NSW1" in r.getMessage() for r in warning_records) + + +# --------------------------------------------------------------------------- +# AC-7: invalid region → ValueError before any network call +# --------------------------------------------------------------------------- + + +def test_invalid_region_raises_valueerror_before_network_call(): + factory = _mock_async_client(_build_response("NSW1")) + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + + with _patch_sdk(factory): + with pytest.raises(ValueError) as exc_info: + asyncio.run(src.fetch_current_price("INVALID1")) + + assert "INVALID1" in str(exc_info.value) + factory.return_value.get_market.assert_not_called() + + +# --------------------------------------------------------------------------- +# Defensive: empty API key → ValueError at construction +# --------------------------------------------------------------------------- + + +def test_constructor_rejects_empty_api_key(): + with pytest.raises(ValueError): + OpenElectricityPriceSource(api_key="") + + +# --------------------------------------------------------------------------- +# AC-7b: explicit timeout +# --------------------------------------------------------------------------- + + +def test_timeout_returns_none_and_warns(caplog: pytest.LogCaptureFixture): + """Hung SDK call must return None within bounded time.""" + + async def _hang(**_kwargs: Any) -> Any: + # Sleep well past the test's patched timeout. + await asyncio.sleep(60) + + factory = _mock_async_client(_hang) + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + + # Patch the constant to 0.05s for the test. + patch_timeout = patch( + "custom_components.pricehawk.providers.openelectricity._FETCH_TIMEOUT_SECONDS", + 0.05, + ) + + with _patch_sdk(factory), patch_timeout, caplog.at_level( + logging.WARNING, + logger="custom_components.pricehawk.providers.openelectricity", + ): + result = asyncio.run(src.fetch_current_price("NSW1")) + + assert result is None + assert any("timed out" in r.getMessage() for r in caplog.records) + + +# --------------------------------------------------------------------------- +# AC-7c: missing SDK → ConfigEntryNotReady (not ImportError) +# --------------------------------------------------------------------------- + + +def test_missing_sdk_raises_config_entry_not_ready(): + from homeassistant.exceptions import ConfigEntryNotReady + + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + + # Force the lazy import to fail. Setting sys.modules[name]=None makes + # Python's import machinery raise ImportError on `from name import ...`. + with patch.dict( + sys.modules, + {"openelectricity": None, "openelectricity.types": None}, + ): + with pytest.raises(ConfigEntryNotReady) as exc_info: + asyncio.run(src.fetch_current_price("NSW1")) + + msg = str(exc_info.value) + assert "openelectricity" in msg.lower() + # Must NOT be a bare ImportError (HA wouldn't retry). + assert not isinstance(exc_info.value, ImportError) + + +# --------------------------------------------------------------------------- +# AC-7d: 429 rate-limit → None, preserves cache, distinct WARNING, no AuthFailed +# --------------------------------------------------------------------------- + + +def test_429_rate_limit_returns_none_preserves_cache( + caplog: pytest.LogCaptureFixture, +): + from homeassistant.exceptions import ConfigEntryAuthFailed + + src = OpenElectricityPriceSource(api_key="sk-test-12345678") + + # First successful fetch — populate cache. + factory_ok = _mock_async_client(_build_response("NSW1", price=85.42)) + with _patch_sdk(factory_ok): + first = asyncio.run(src.fetch_current_price("NSW1")) + assert first is not None + + # 429 hit. + factory_429 = _mock_async_client( + RuntimeError("HTTP 429 Too Many Requests") + ) + second: WholesalePrice | None = None + with _patch_sdk(factory_429), caplog.at_level( + logging.WARNING, + logger="custom_components.pricehawk.providers.openelectricity", + ): + try: + second = asyncio.run(src.fetch_current_price("NSW1")) + except ConfigEntryAuthFailed: + pytest.fail("429 must NOT raise ConfigEntryAuthFailed") + + assert second is None + assert src.last_good("NSW1") is first # cache preserved + rate_limit_logs = [ + r for r in caplog.records if "rate-limited" in r.getMessage() + ] + assert len(rate_limit_logs) >= 1 + + +# --------------------------------------------------------------------------- +# AC-7e (part 1): API key redacted in repr +# --------------------------------------------------------------------------- + + +def test_repr_redacts_api_key(): + api_key = "sk-test-1234567890abcdef" + src = OpenElectricityPriceSource(api_key=api_key) + + rendered = repr(src) + assert api_key not in rendered, "FULL API KEY LEAKED IN REPR" + # No 8-char prefix either. + assert api_key[:8] not in rendered, "API KEY PREFIX LEAKED IN REPR" + assert "