M6-T05: add Tibber GraphQL client + price refresh service + schedule
- app/integrations/tibber/client.py: fetch_price_range/fetch_current_price (priceInfoRange QUARTER_HOURLY, startsAt->UTC, no fixed node count assumption), TibberError/TibberAuthError; token never logged or put in exception text. - app/services/tibber_prices.py: refresh_prices upserts by starts_at (idempotent), no-op unless an active kind=tibber contract + token exist. - main.py: hourly tibber-refresh job (existing jobs/MQTT untouched). Tests added.
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
"""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,
|
||||
},
|
||||
]
|
||||
|
||||
_PRICE_RANGE_RESPONSE = {
|
||||
"data": {
|
||||
"viewer": {
|
||||
"homes": [
|
||||
{
|
||||
"id": "home-id-1",
|
||||
"currentSubscription": {
|
||||
"priceInfoRange": {
|
||||
"nodes": _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 node only.
|
||||
one_node_response = {
|
||||
"data": {
|
||||
"viewer": {
|
||||
"homes": [
|
||||
{
|
||||
"id": "home-id-1",
|
||||
"currentSubscription": {
|
||||
"priceInfoRange": {
|
||||
"nodes": [_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_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": {
|
||||
"priceInfoRange": {
|
||||
"nodes": [_THREE_NODES[0]],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "home-id-second",
|
||||
"currentSubscription": {
|
||||
"priceInfoRange": {
|
||||
"nodes": [_THREE_NODES[1], _THREE_NODES[2]],
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
Reference in New Issue
Block a user