"""Tests for M6-T05: Tibber API client (app/integrations/tibber/client.py). Covers: 1. ``fetch_price_range``: happy path parses nodes into PricePoint objects with UTC-aware starts_at; does not assume a fixed number of nodes (tested with 3). 2. ``fetch_price_range``: HTTP 401 → TibberAuthError. 3. ``fetch_price_range``: HTTP 403 → TibberAuthError. 4. ``fetch_price_range``: httpx.TimeoutException → TibberError. 5. ``fetch_price_range``: generic HTTP error → TibberError. 6. Token does not appear in any raised exception's string representation. 7. ``fetch_price_range``: home_id selection (picks correct home). 8. ``fetch_current_price``: happy path returns a single PricePoint. 9. ``fetch_current_price``: HTTP 401 → TibberAuthError. 10. GraphQL-level errors in response body → TibberError. All HTTP calls are intercepted via ``httpx.MockTransport`` (httpx built-in) so no network access is required. """ from __future__ import annotations import json from datetime import UTC, datetime, timedelta import httpx import pytest from app.integrations.tibber.client import ( PricePoint, TibberAuthError, TibberError, fetch_current_price, fetch_price_range, ) # --------------------------------------------------------------------------- # Helpers / fixture builders # --------------------------------------------------------------------------- _FAKE_TOKEN = "fake-secret-token-do-not-log" # A minimal set of 3 price nodes to verify that the parser does not assume a # fixed number of nodes (e.g. 96). _THREE_NODES = [ { "startsAt": "2026-06-23T00:00:00.000+02:00", "total": 0.25, "energy": 0.20, "tax": 0.05, "currency": "EUR", "level": "NORMAL", }, { "startsAt": "2026-06-23T00:15:00.000+02:00", "total": 0.22, "energy": 0.18, "tax": 0.04, "currency": "EUR", "level": "CHEAP", }, { "startsAt": "2026-06-23T00:30:00.000+02:00", "total": 0.30, "energy": 0.24, "tax": 0.06, "currency": "EUR", "level": None, }, ] def _price_info_response(today: list[dict], tomorrow: list[dict] | None = None) -> dict: """Build a priceInfo(resolution: QUARTER_HOURLY) { today tomorrow } response.""" return { "data": { "viewer": { "homes": [ { "id": "home-id-1", "currentSubscription": { "priceInfo": { "today": today, "tomorrow": tomorrow if tomorrow is not None else [], } }, } ] } } } _PRICE_RANGE_RESPONSE = _price_info_response(_THREE_NODES) _CURRENT_PRICE_RESPONSE = { "data": { "viewer": { "homes": [ { "id": "home-id-1", "currentSubscription": { "priceInfo": { "current": { "startsAt": "2026-06-23T10:00:00.000+02:00", "total": 0.28, "energy": 0.22, "tax": 0.06, "currency": "EUR", "level": "EXPENSIVE", } } }, } ] } } } def _make_transport(status_code: int, body: dict | None = None, raise_timeout: bool = False): """Create an ``httpx.MockTransport`` that returns the given response. If *raise_timeout* is True, the transport raises ``httpx.TimeoutException`` instead of returning a response. """ def _handler(request: httpx.Request) -> httpx.Response: if raise_timeout: raise httpx.TimeoutException("timed out", request=request) payload = json.dumps(body or {}).encode() return httpx.Response( status_code=status_code, content=payload, headers={"Content-Type": "application/json"}, ) return httpx.MockTransport(_handler) # --------------------------------------------------------------------------- # fetch_price_range — happy path # --------------------------------------------------------------------------- def test_fetch_price_range_parses_nodes(monkeypatch): """Successful response with 3 nodes is parsed into 3 PricePoint objects.""" transport = _make_transport(200, _PRICE_RANGE_RESPONSE) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) points = fetch_price_range(_FAKE_TOKEN) assert len(points) == 3 # All starts_at must be UTC-aware. for p in points: assert isinstance(p, PricePoint) assert p.starts_at.tzinfo is not None assert p.starts_at.tzinfo == UTC or p.starts_at.utcoffset() == timedelta(0) # First node: 2026-06-23T00:00:00+02:00 → 2026-06-22T22:00:00Z first = points[0] assert first.starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC) assert first.total == pytest.approx(0.25) assert first.energy == pytest.approx(0.20) assert first.tax == pytest.approx(0.05) assert first.currency == "EUR" assert first.level == "NORMAL" assert first.resolution == "QUARTER_HOURLY" # Third node has level=None in the raw API response. third = points[2] assert third.level is None def test_fetch_price_range_does_not_assume_node_count(monkeypatch): """Parser handles an arbitrary number of nodes (not hardcoded to 96).""" # Build a response with a single today node and no tomorrow yet. one_node_response = _price_info_response([_THREE_NODES[0]]) transport = _make_transport(200, one_node_response) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) points = fetch_price_range(_FAKE_TOKEN) assert len(points) == 1 def test_fetch_price_range_concatenates_today_and_tomorrow(monkeypatch): """today and tomorrow node lists are both parsed (today first, then tomorrow).""" tomorrow_nodes = [ { "startsAt": "2026-06-24T00:00:00.000+02:00", "total": 0.40, "energy": 0.32, "tax": 0.08, "currency": "EUR", "level": "EXPENSIVE", }, ] response = _price_info_response(_THREE_NODES, tomorrow_nodes) transport = _make_transport(200, response) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) points = fetch_price_range(_FAKE_TOKEN) # 3 today + 1 tomorrow, in order. assert len(points) == 4 # First node is today's first; last node is tomorrow's. assert points[0].starts_at == datetime(2026, 6, 22, 22, 0, 0, tzinfo=UTC) # 2026-06-24T00:00:00+02:00 → 2026-06-23T22:00:00Z assert points[-1].starts_at == datetime(2026, 6, 23, 22, 0, 0, tzinfo=UTC) assert points[-1].total == pytest.approx(0.40) def test_fetch_price_range_tomorrow_null_returns_today_only(monkeypatch): """A null tomorrow (before day-ahead publication) yields today's nodes only.""" response = { "data": { "viewer": { "homes": [ { "id": "home-id-1", "currentSubscription": { "priceInfo": { "today": _THREE_NODES, "tomorrow": None, } }, } ] } } } transport = _make_transport(200, response) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) points = fetch_price_range(_FAKE_TOKEN) assert len(points) == 3 def test_fetch_price_range_home_id_selection(monkeypatch): """When home_id is specified, the matching home is selected.""" two_homes_response = { "data": { "viewer": { "homes": [ { "id": "home-id-first", "currentSubscription": { "priceInfo": { "today": [_THREE_NODES[0]], "tomorrow": [], } }, }, { "id": "home-id-second", "currentSubscription": { "priceInfo": { "today": [_THREE_NODES[1], _THREE_NODES[2]], "tomorrow": [], } }, }, ] } } } transport = _make_transport(200, two_homes_response) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) # Selecting the second home should return 2 nodes. points = fetch_price_range(_FAKE_TOKEN, home_id="home-id-second") assert len(points) == 2 # Selecting the first home (default when home_id=None) returns 1 node. points_default = fetch_price_range(_FAKE_TOKEN) assert len(points_default) == 1 # --------------------------------------------------------------------------- # fetch_price_range — authentication errors # --------------------------------------------------------------------------- @pytest.mark.parametrize("status_code", [401, 403]) def test_fetch_price_range_auth_error(monkeypatch, status_code): """HTTP 401 or 403 raises TibberAuthError (subclass of TibberError).""" transport = _make_transport(status_code, {}) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberAuthError): fetch_price_range(_FAKE_TOKEN) def test_fetch_price_range_auth_error_is_tibber_error(monkeypatch): """TibberAuthError is a subclass of TibberError for broad catches.""" transport = _make_transport(401, {}) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberError): fetch_price_range(_FAKE_TOKEN) # --------------------------------------------------------------------------- # fetch_price_range — network / timeout errors # --------------------------------------------------------------------------- def test_fetch_price_range_timeout_raises_tibber_error(monkeypatch): """httpx.TimeoutException is wrapped in TibberError.""" transport = _make_transport(200, raise_timeout=True) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberError): fetch_price_range(_FAKE_TOKEN) def test_fetch_price_range_timeout_not_auth_error(monkeypatch): """A timeout should raise TibberError but NOT TibberAuthError.""" transport = _make_transport(200, raise_timeout=True) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberError) as exc_info: fetch_price_range(_FAKE_TOKEN) assert not isinstance(exc_info.value, TibberAuthError) def test_fetch_price_range_http_500_raises_tibber_error(monkeypatch): """An unexpected 5xx response raises TibberError.""" transport = _make_transport(500, {"error": "internal"}) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberError): fetch_price_range(_FAKE_TOKEN) # --------------------------------------------------------------------------- # Token safety — token must not appear in exception messages # --------------------------------------------------------------------------- def test_token_not_in_auth_error_message(monkeypatch): """The API token must not appear in TibberAuthError's string representation.""" transport = _make_transport(401, {}) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberAuthError) as exc_info: fetch_price_range(_FAKE_TOKEN) # The token must not appear anywhere in the exception string. assert _FAKE_TOKEN not in str(exc_info.value) assert _FAKE_TOKEN not in repr(exc_info.value) def test_token_not_in_timeout_error_message(monkeypatch): """The API token must not appear in TibberError's string representation on timeout.""" transport = _make_transport(200, raise_timeout=True) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberError) as exc_info: fetch_price_range(_FAKE_TOKEN) assert _FAKE_TOKEN not in str(exc_info.value) assert _FAKE_TOKEN not in repr(exc_info.value) # --------------------------------------------------------------------------- # GraphQL errors in response body # --------------------------------------------------------------------------- def test_graphql_errors_raise_tibber_error(monkeypatch): """A 200 response that contains a GraphQL 'errors' field raises TibberError.""" graphql_error_body = { "errors": [{"message": "Some GraphQL error"}], "data": None, } transport = _make_transport(200, graphql_error_body) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberError): fetch_price_range(_FAKE_TOKEN) # --------------------------------------------------------------------------- # fetch_current_price — happy path # --------------------------------------------------------------------------- def test_fetch_current_price_returns_single_price_point(monkeypatch): """Successful response returns a single UTC-aware PricePoint.""" transport = _make_transport(200, _CURRENT_PRICE_RESPONSE) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) point = fetch_current_price(_FAKE_TOKEN) assert isinstance(point, PricePoint) # 2026-06-23T10:00:00+02:00 → 2026-06-23T08:00:00Z assert point.starts_at == datetime(2026, 6, 23, 8, 0, 0, tzinfo=UTC) assert point.total == pytest.approx(0.28) assert point.energy == pytest.approx(0.22) assert point.tax == pytest.approx(0.06) assert point.currency == "EUR" assert point.level == "EXPENSIVE" assert point.resolution == "QUARTER_HOURLY" # UTC-aware check assert point.starts_at.tzinfo is not None # --------------------------------------------------------------------------- # fetch_current_price — authentication errors # --------------------------------------------------------------------------- def test_fetch_current_price_auth_error(monkeypatch): """HTTP 401 on current-price query raises TibberAuthError.""" transport = _make_transport(401, {}) def _patched_post(url, *, json, headers, timeout): # noqa: A002 client = httpx.Client(transport=transport) return client.post(url, json=json, headers=headers, timeout=timeout) monkeypatch.setattr("app.integrations.tibber.client.httpx.post", _patched_post) with pytest.raises(TibberAuthError): fetch_current_price(_FAKE_TOKEN)