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:
2026-06-23 21:35:29 +02:00
parent 4284bd40a7
commit 7e266a21eb
7 changed files with 1351 additions and 1 deletions
+439
View File
@@ -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)
+394
View File
@@ -0,0 +1,394 @@
"""Tests for M6-T05: Tibber price refresh service (app/services/tibber_prices.py).
Covers:
1. With active tibber contract + non-empty token: ``refresh_prices`` calls the
client, upserts returned price points, and returns the count.
2. Idempotency: running ``refresh_prices`` twice does not double-insert rows;
rows are updated in-place (``starts_at`` remains unique).
3. No active contract → no-op (returns 0, no DB writes).
4. Active contract with kind="manual" (not tibber) → no-op.
5. Token is empty string → no-op.
6. Token is whitespace-only → no-op.
7. Client errors propagate (not swallowed by the service).
All Tibber API calls are intercepted via monkeypatching ``fetch_price_range``
so no real network access is needed.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.integrations.tibber.client import PricePoint, TibberError
from app.models.energy import EnergyContract, EnergyContractVersion, TibberPrice
from app.services.tibber_prices import refresh_prices
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _make_app_alembic_config(database_url: str) -> Config:
cfg = Config("alembic_app.ini")
cfg.set_main_option("sqlalchemy.url", database_url)
return cfg
@pytest.fixture()
def energy_db(tmp_path: Path):
"""Temporary SQLite DB upgraded to head (has all energy tables)."""
db_path = tmp_path / "tibber_prices_test.db"
db_url = f"sqlite:///{db_path}"
alembic_cfg = _make_app_alembic_config(db_url)
command.upgrade(alembic_cfg, "head")
engine = create_engine(db_url, connect_args={"check_same_thread": False})
session = Session(engine)
yield session
session.close()
engine.dispose()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_UTC = UTC
def _ts(hour: int, minute: int = 0) -> datetime:
"""Return a UTC datetime on 2026-06-23 at the given hour:minute."""
return datetime(2026, 6, 23, hour, minute, 0, tzinfo=_UTC)
def _make_price_points(count: int = 3) -> list[PricePoint]:
"""Build a list of *count* fake PricePoint objects 15 minutes apart."""
base = _ts(10, 0)
points = []
for i in range(count):
starts_at = base + timedelta(minutes=15 * i)
points.append(
PricePoint(
starts_at=starts_at,
total=0.20 + i * 0.01,
energy=0.16 + i * 0.01,
tax=0.04,
currency="EUR",
level="NORMAL",
resolution="QUARTER_HOURLY",
)
)
return points
class _FakeSettings:
"""Minimal settings stand-in with Tibber fields."""
def __init__(self, token: str = "valid-token", home_id: str = ""):
self.tibber_api_token = token
self.tibber_home_id = home_id
def _create_active_tibber_contract(session: Session) -> EnergyContract:
"""Insert and commit an active tibber contract + one open version."""
now = datetime.now(UTC)
contract = EnergyContract(
name="Tibber Test",
kind="tibber",
active=True,
currency="EUR",
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush()
version = EnergyContractVersion(
contract_id=contract.id,
effective_from=now,
effective_to=None,
values={"energy": {"energy_tax": 0.1108, "sell_adjust": 0.0}, "standing": {}, "credits": {}},
created_at=now,
)
session.add(version)
session.commit()
return contract
def _create_active_manual_contract(session: Session) -> EnergyContract:
"""Insert and commit an active manual contract."""
now = datetime.now(UTC)
contract = EnergyContract(
name="Manual Test",
kind="manual",
active=True,
currency="EUR",
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush()
version = EnergyContractVersion(
contract_id=contract.id,
effective_from=now,
effective_to=None,
values={},
created_at=now,
)
session.add(version)
session.commit()
return contract
def _count_tibber_price_rows(session: Session) -> int:
"""Return the current number of rows in tibber_price."""
return len(session.execute(select(TibberPrice)).scalars().all())
# ---------------------------------------------------------------------------
# Test: happy path (active tibber contract + valid token)
# ---------------------------------------------------------------------------
def test_refresh_prices_upserts_price_points(energy_db, monkeypatch):
"""Active tibber contract + token → price points are upserted and count returned."""
session = energy_db
_create_active_tibber_contract(session)
points = _make_price_points(3)
monkeypatch.setattr(
"app.services.tibber_prices.fetch_price_range",
lambda token, home_id_or_none: points,
)
count = refresh_prices(session, _FakeSettings())
assert count == 3
rows = session.execute(select(TibberPrice).order_by(TibberPrice.starts_at)).scalars().all()
assert len(rows) == 3
for row, point in zip(rows, points):
# starts_at is stored UTC-aware; SQLite may return naive — normalise.
stored_starts_at = row.starts_at
if stored_starts_at.tzinfo is None:
stored_starts_at = stored_starts_at.replace(tzinfo=UTC)
assert stored_starts_at == point.starts_at
assert row.total == pytest.approx(point.total)
assert row.energy == pytest.approx(point.energy)
assert row.tax == pytest.approx(point.tax)
assert row.currency == point.currency
assert row.resolution == "QUARTER_HOURLY"
# ---------------------------------------------------------------------------
# Test: idempotency (running twice must not double-insert)
# ---------------------------------------------------------------------------
def test_refresh_prices_is_idempotent(energy_db, monkeypatch):
"""Running refresh_prices twice does not increase the row count."""
session = energy_db
_create_active_tibber_contract(session)
points = _make_price_points(4)
call_count = {"n": 0}
def _fake_fetch(token, home_id_or_none):
call_count["n"] += 1
return points
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
count1 = refresh_prices(session, _FakeSettings())
count2 = refresh_prices(session, _FakeSettings())
assert call_count["n"] == 2 # fetch was called twice
assert count1 == 4
assert count2 == 4 # same number of "upserted" (all updated in-place)
# DB must still have exactly 4 rows (not 8).
assert _count_tibber_price_rows(session) == 4
def test_refresh_prices_idempotent_updates_fields(energy_db, monkeypatch):
"""On second run, existing rows are updated (not duplicated)."""
session = energy_db
_create_active_tibber_contract(session)
points = _make_price_points(2)
monkeypatch.setattr(
"app.services.tibber_prices.fetch_price_range",
lambda token, home_id_or_none: points,
)
refresh_prices(session, _FakeSettings())
# Modify the points to simulate Tibber updating a price.
updated_points = [
PricePoint(
starts_at=p.starts_at,
total=p.total + 0.10, # price changed
energy=p.energy + 0.08,
tax=p.tax + 0.02,
currency=p.currency,
level="EXPENSIVE",
resolution=p.resolution,
)
for p in points
]
monkeypatch.setattr(
"app.services.tibber_prices.fetch_price_range",
lambda token, home_id_or_none: updated_points,
)
refresh_prices(session, _FakeSettings())
rows = session.execute(select(TibberPrice).order_by(TibberPrice.starts_at)).scalars().all()
assert len(rows) == 2 # still 2, not 4
for row, up in zip(rows, updated_points):
assert row.total == pytest.approx(up.total)
assert row.level == "EXPENSIVE"
# ---------------------------------------------------------------------------
# Test: no-op conditions
# ---------------------------------------------------------------------------
def test_refresh_prices_noop_no_active_contract(energy_db, monkeypatch):
"""No active contract at all → no-op (returns 0, no DB writes)."""
session = energy_db
fetch_called = {"called": False}
def _fake_fetch(token, home_id_or_none):
fetch_called["called"] = True
return _make_price_points(3)
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
count = refresh_prices(session, _FakeSettings())
assert count == 0
assert not fetch_called["called"]
assert _count_tibber_price_rows(session) == 0
def test_refresh_prices_noop_manual_contract(energy_db, monkeypatch):
"""Active manual contract (not tibber) → no-op."""
session = energy_db
_create_active_manual_contract(session)
fetch_called = {"called": False}
def _fake_fetch(token, home_id_or_none):
fetch_called["called"] = True
return _make_price_points(3)
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
count = refresh_prices(session, _FakeSettings())
assert count == 0
assert not fetch_called["called"]
assert _count_tibber_price_rows(session) == 0
def test_refresh_prices_noop_empty_token(energy_db, monkeypatch):
"""Empty token → no-op even if tibber contract is active."""
session = energy_db
_create_active_tibber_contract(session)
fetch_called = {"called": False}
def _fake_fetch(token, home_id_or_none):
fetch_called["called"] = True
return _make_price_points(3)
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
count = refresh_prices(session, _FakeSettings(token=""))
assert count == 0
assert not fetch_called["called"]
assert _count_tibber_price_rows(session) == 0
def test_refresh_prices_noop_whitespace_token(energy_db, monkeypatch):
"""Whitespace-only token → no-op (treated as not configured)."""
session = energy_db
_create_active_tibber_contract(session)
fetch_called = {"called": False}
def _fake_fetch(token, home_id_or_none):
fetch_called["called"] = True
return _make_price_points(2)
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
count = refresh_prices(session, _FakeSettings(token=" "))
assert count == 0
assert not fetch_called["called"]
# ---------------------------------------------------------------------------
# Test: inactive tibber contract
# ---------------------------------------------------------------------------
def test_refresh_prices_noop_inactive_tibber_contract(energy_db, monkeypatch):
"""Inactive tibber contract → no-op (active flag is False)."""
session = energy_db
# Create a tibber contract but do NOT activate it.
now = datetime.now(UTC)
contract = EnergyContract(
name="Inactive Tibber",
kind="tibber",
active=False, # <-- inactive
currency="EUR",
created_at=now,
updated_at=now,
)
session.add(contract)
session.commit()
fetch_called = {"called": False}
def _fake_fetch(token, home_id_or_none):
fetch_called["called"] = True
return _make_price_points(2)
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _fake_fetch)
count = refresh_prices(session, _FakeSettings())
assert count == 0
assert not fetch_called["called"]
# ---------------------------------------------------------------------------
# Test: client errors propagate
# ---------------------------------------------------------------------------
def test_refresh_prices_propagates_client_error(energy_db, monkeypatch):
"""TibberError from the client is NOT swallowed by the service."""
session = energy_db
_create_active_tibber_contract(session)
def _failing_fetch(token, home_id_or_none):
raise TibberError("simulated network failure")
monkeypatch.setattr("app.services.tibber_prices.fetch_price_range", _failing_fetch)
with pytest.raises(TibberError):
refresh_prices(session, _FakeSettings())
# No rows should have been written.
assert _count_tibber_price_rows(session) == 0