Files
home-automation/tests/test_tibber_client.py
T
tliu93 b65f700d56
pytest / test (push) Successful in 20m2s
frontend / frontend (push) Failing after 6m5s
docker-image / build-and-push (push) Successful in 13m27s
fix(tibber): fetch forward-looking today+tomorrow via priceInfo(QUARTER_HOURLY)
priceInfoRange is a historical cursor connection whose range ends at "now": it
never returns upcoming slots. With it, the DB only ever held prices up to the
last hourly refresh, which caused two problems:

  1. The price chart could only show history up to now, never a forward curve.
  2. Worse, per-slot billing was subtly wrong. _tibber_strategy looks up the
     price via `starts_at <= t0` (nearest slot at or before the period). Because
     a period's exact 15-min slot was not fetched until the next hourly refresh
     (~1h later), intra-hour periods were billed with the PREVIOUS quarter's
     price and then locked in by the immutability guard — never corrected.

Switch to priceInfo(resolution: QUARTER_HOURLY) { today tomorrow }, which is
forward-looking AND quarter-hourly: today is always the full local day (96
slots) and tomorrow fills in once Tibber publishes day-ahead prices (picked up
by the next hourly refresh). Every 15-min slot's exact price is now in the DB
before the slot closes, so each period finds its own slot (accurate billing)
and the live current-price entity stays fresh.

Verified against the live Tibber API: fetch_price_range returns 192 points
(96 today + 96 tomorrow), spanning local today 00:00 → tomorrow 23:45, with
future slots present (previously 0).
2026-07-18 20:20:33 +02:00

495 lines
18 KiB
Python

"""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)