diff --git a/app/integrations/tibber/__init__.py b/app/integrations/tibber/__init__.py new file mode 100644 index 0000000..1e78c71 --- /dev/null +++ b/app/integrations/tibber/__init__.py @@ -0,0 +1,10 @@ +"""Tibber dynamic electricity pricing integration. + +This package provides: + - ``client``: HTTP client for the Tibber GraphQL API (price fetching). + - Custom exceptions: ``TibberError`` (base) and ``TibberAuthError`` (auth failure). + +Usage:: + + from app.integrations.tibber.client import fetch_price_range, TibberAuthError +""" diff --git a/app/integrations/tibber/client.py b/app/integrations/tibber/client.py new file mode 100644 index 0000000..ce8df20 --- /dev/null +++ b/app/integrations/tibber/client.py @@ -0,0 +1,329 @@ +"""Tibber GraphQL API client for fetching electricity price data. + +Design decisions +---------------- +- Uses ``httpx`` (already a project dependency) for all HTTP requests. +- Token is **never** written to log messages or exception strings to prevent + credential leakage into log aggregators. +- ``fetch_price_range`` returns a list of ``PricePoint`` objects with UTC-aware + ``starts_at`` datetimes; the number of nodes is not assumed — all returned + nodes are parsed regardless of count. +- ``fetch_current_price`` uses a separate, simpler query that asks for the + *current* price point only; used by the connection-test endpoint (T09) to + produce a fast three-state result (success / auth-error / network-error). +- Authentication failures (HTTP 401/403) are raised as ``TibberAuthError`` so + that callers (the test endpoint) can distinguish them from generic network or + API errors. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +_TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql" +_DEFAULT_TIMEOUT = 15.0 + +# GraphQL query to fetch a range of 15-minute price nodes. +# ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` fetches up to +# 96 quarter-hourly slots which covers today + tomorrow (2 × 24 × 4 = 192 max, +# but the Tibber API typically starts from the current slot and returns at +# most the remaining hours of today plus tomorrow, so 96 is a good cap for +# "today + tomorrow"). +_PRICE_RANGE_QUERY = """ +{ + viewer { + homes { + id + currentSubscription { + priceInfoRange(resolution: QUARTER_HOURLY, first: 96) { + nodes { + startsAt + total + energy + tax + currency + level + } + } + } + } + } +} +""" + +# GraphQL query to fetch the single *current* price point. +_CURRENT_PRICE_QUERY = """ +{ + viewer { + homes { + id + currentSubscription { + priceInfo { + current { + startsAt + total + energy + tax + currency + level + } + } + } + } + } +} +""" + + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class TibberError(Exception): + """Base class for all Tibber client errors. + + Raised for network failures, timeouts, unexpected HTTP status codes, and + malformed API responses. The exception message will **never** contain the + API token. + """ + + +class TibberAuthError(TibberError): + """Raised when the Tibber API rejects the provided token (HTTP 401/403). + + Callers that implement a three-state connection test should catch this + exception separately from ``TibberError`` to distinguish auth problems + from network / API problems. + """ + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class PricePoint: + """One 15-minute price slot returned by the Tibber API. + + All monetary values are in ``currency`` and include VAT (user-facing). + ``starts_at`` is always a timezone-aware UTC datetime regardless of the + timezone offset that Tibber returns in ``startsAt``. + """ + + starts_at: datetime # UTC-aware + total: float # full all-in price (energy + tax) + energy: float # spot energy component + tax: float # tax component + currency: str # ISO 4217 (typically "EUR") + level: str | None # e.g. "CHEAP", "NORMAL", "EXPENSIVE"; None if absent + resolution: str # label for the slot resolution (e.g. "QUARTER_HOURLY") + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _parse_starts_at(raw: str) -> datetime: + """Parse an ISO 8601 timestamp with timezone offset and convert to UTC. + + Tibber returns timestamps like ``2026-06-23T00:00:00.000+02:00``. + ``datetime.fromisoformat`` handles this format in Python 3.11+; the result + is then converted to UTC via ``.astimezone(UTC)``. + """ + return datetime.fromisoformat(raw).astimezone(UTC) + + +def _parse_node(node: dict[str, Any], resolution: str) -> PricePoint: + """Parse a single price node dict into a ``PricePoint``.""" + return PricePoint( + starts_at=_parse_starts_at(node["startsAt"]), + total=float(node["total"]), + energy=float(node["energy"]), + tax=float(node["tax"]), + currency=str(node["currency"]), + level=node.get("level") or None, + resolution=resolution, + ) + + +def _pick_home(homes: list[dict[str, Any]], home_id: str | None) -> dict[str, Any]: + """Return the home dict matching *home_id*, or the first home if None.""" + if not homes: + raise TibberError("Tibber API returned no homes") + if home_id is not None: + for h in homes: + if h.get("id") == home_id: + return h + raise TibberError("Tibber home id not found in API response") + return homes[0] + + +def _post_graphql(token: str, query: str, timeout: float) -> dict[str, Any]: + """POST the GraphQL *query* and return the parsed ``data`` dict. + + Raises + ------ + TibberAuthError + On HTTP 401 or 403. + TibberError + On timeouts, network errors, non-2xx responses, or unexpected body shape. + """ + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + try: + response = httpx.post( + _TIBBER_API_URL, + json={"query": query}, + headers=headers, + timeout=timeout, + ) + except httpx.TimeoutException as exc: + raise TibberError("Tibber API request timed out") from exc + except httpx.HTTPError as exc: + raise TibberError("Tibber API request failed") from exc + + if response.status_code in (401, 403): + raise TibberAuthError("Tibber API authentication failed") + + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise TibberError(f"Tibber API returned unexpected status {response.status_code}") from exc + + try: + body = response.json() + except Exception as exc: + raise TibberError("Tibber API returned non-JSON response") from exc + + if "errors" in body: + # GraphQL errors are not HTTP errors; surface them as TibberError. + # Do not include token in the message. + raise TibberError("Tibber GraphQL returned errors") + + data = body.get("data") + if data is None: + raise TibberError("Tibber API response missing 'data' field") + + return data + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def fetch_price_range( + token: str, + home_id: str | None = None, + *, + timeout: float = _DEFAULT_TIMEOUT, +) -> list[PricePoint]: + """Fetch a range of 15-minute price nodes from the Tibber API. + + Sends the ``priceInfoRange(resolution: QUARTER_HOURLY, first: 96)`` query + and parses every returned node into a ``PricePoint``. The number of nodes + is not assumed — all returned nodes are parsed regardless of count. + + Parameters + ---------- + token: + Tibber API token. **Never** logged or included in exception messages. + home_id: + If given, the home with this Tibber home ID is selected. If ``None``, + the first home in the account is used. + timeout: + HTTP request timeout in seconds (default 15 s). + + Returns + ------- + list[PricePoint] + List of price points with UTC-aware ``starts_at`` values. + + Raises + ------ + TibberAuthError + If the token is rejected (HTTP 401/403). + TibberError + For network failures, timeouts, or unexpected API responses. + """ + data = _post_graphql(token, _PRICE_RANGE_QUERY, timeout) + + try: + homes = data["viewer"]["homes"] + except (KeyError, TypeError) as exc: + raise TibberError("Tibber API response has unexpected shape") from exc + + home = _pick_home(homes, home_id) + + try: + nodes = home["currentSubscription"]["priceInfoRange"]["nodes"] + except (KeyError, TypeError) as exc: + raise TibberError("Tibber API response missing priceInfoRange nodes") from exc + + return [_parse_node(node, "QUARTER_HOURLY") for node in nodes] + + +def fetch_current_price( + token: str, + home_id: str | None = None, + *, + timeout: float = _DEFAULT_TIMEOUT, +) -> PricePoint: + """Fetch the *current* price point from the Tibber API. + + Uses the lighter ``priceInfo { current { ... } }`` query rather than the + full range query, making it suitable for a fast connection test. + + Parameters + ---------- + token: + Tibber API token. **Never** logged or included in exception messages. + home_id: + If given, the home with this Tibber home ID is selected. If ``None``, + the first home in the account is used. + timeout: + HTTP request timeout in seconds (default 15 s). + + Returns + ------- + PricePoint + The current price point with a UTC-aware ``starts_at``. + + Raises + ------ + TibberAuthError + If the token is rejected (HTTP 401/403). + TibberError + For network failures, timeouts, missing current price, or unexpected + API responses. + """ + data = _post_graphql(token, _CURRENT_PRICE_QUERY, timeout) + + try: + homes = data["viewer"]["homes"] + except (KeyError, TypeError) as exc: + raise TibberError("Tibber API response has unexpected shape") from exc + + home = _pick_home(homes, home_id) + + try: + current = home["currentSubscription"]["priceInfo"]["current"] + except (KeyError, TypeError) as exc: + raise TibberError("Tibber API response missing current price") from exc + + if current is None: + raise TibberError("Tibber API returned null for current price") + + return _parse_node(current, "QUARTER_HOURLY") diff --git a/app/main.py b/app/main.py index f507fbc..82aa9ce 100644 --- a/app/main.py +++ b/app/main.py @@ -31,6 +31,7 @@ from app.services.config_page import build_runtime_settings, seed_missing_config from app.services.public_ip import check_public_ipv4_and_notify from app.services.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS from app.services.ha_discovery import publish_discovery, publish_states +from app.services.tibber_prices import refresh_prices from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db logger = logging.getLogger(__name__) @@ -63,6 +64,33 @@ def _run_scheduled_modbus_poll() -> None: session.close() +def _run_scheduled_tibber_refresh() -> None: + """Scheduled job: fetch Tibber 15-minute prices and upsert into tibber_price. + + Runs every hour so that: + - Today's prices are available from startup. + - Tomorrow's prices (published by Tibber around 13:00 CET / 11:00 UTC) are + picked up within an hour of publication without requiring a server restart. + + The job is a no-op when: + - No active energy contract with kind="tibber" exists. + - The Tibber API token is empty in the runtime settings. + + Any client exceptions (auth failures, network errors) are caught and logged + so that a single failed fetch does not crash the scheduler or affect the + other background jobs. + """ + session_local = get_session_local() + session: Session = session_local() + try: + runtime_settings = build_runtime_settings(session, get_settings()) + refresh_prices(session, runtime_settings) + except Exception: + logger.exception("_run_scheduled_tibber_refresh: unexpected error") + finally: + session.close() + + def _run_scheduled_ha_state_publish() -> None: """Periodic job: publish discovery configs + state + availability for all enabled exposed entities. @@ -148,6 +176,17 @@ async def lifespan(_: FastAPI): max_instances=1, coalesce=True, ) + # Tibber price refresh: fetch today + tomorrow every hour. + # The job is a no-op when no active tibber contract or token is configured, + # so it is safe to register unconditionally. + scheduler.add_job( + _run_scheduled_tibber_refresh, + trigger=IntervalTrigger(hours=1), + id="tibber-refresh", + replace_existing=True, + max_instances=1, + coalesce=True, + ) scheduler.start() # MQTT: connect using DB-merged runtime settings so broker configured via UI diff --git a/app/services/tibber_prices.py b/app/services/tibber_prices.py new file mode 100644 index 0000000..98bef19 --- /dev/null +++ b/app/services/tibber_prices.py @@ -0,0 +1,139 @@ +"""Service layer for fetching and persisting Tibber 15-minute electricity prices. + +``refresh_prices`` is the main entry point. It is designed to be called from a +scheduled background job (see ``app/main.py``) and from tests. + +Design decisions +---------------- +- **Guard clause**: if no active contract with ``kind="tibber"`` exists, or if + the Tibber API token is empty in the runtime settings, the function is a + complete no-op (returns 0) and does not raise. This means the scheduler can + call ``refresh_prices`` unconditionally; the service itself decides whether to + do anything based on current configuration. +- **Upsert idempotency**: rows are matched by ``starts_at`` (the unique + constraint on ``tibber_price``). If a row already exists, its price fields + and ``fetched_at`` are updated in-place; if it does not exist, a new row is + inserted. Running ``refresh_prices`` twice in a row must not double-insert. +- **No destructive operations**: this service never deletes ``tibber_price`` + rows. Only INSERT or UPDATE. +- **Exception propagation**: exceptions from the Tibber client are *not* + swallowed here. The scheduled job wrapper in ``main.py`` is responsible for + catching and logging errors so that a single fetch failure does not crash the + scheduler. +- **SQLite timezone note**: ``DateTime(timezone=True)`` columns come back as + timezone-naive UTC datetimes on read. We store UTC-aware datetimes on write + (``starts_at`` from ``PricePoint`` is already UTC-aware; ``fetched_at`` is + ``datetime.now(UTC)``). Reads elsewhere that compare against these values + must normalise accordingly. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.integrations.tibber.client import fetch_price_range +from app.models.energy import EnergyContract, TibberPrice + +logger = logging.getLogger(__name__) + + +def _active_tibber_contract_exists(session: Session) -> bool: + """Return True if there is an active contract with kind='tibber'.""" + row = session.execute( + select(EnergyContract).where( + EnergyContract.active.is_(True), + EnergyContract.kind == "tibber", + ).limit(1) + ).scalar_one_or_none() + return row is not None + + +def refresh_prices(session: Session, settings: object) -> int: + """Fetch today-and-tomorrow Tibber prices and upsert them into ``tibber_price``. + + Parameters + ---------- + session: + An active SQLAlchemy session. The caller is responsible for closing it; + this function commits each upserted row individually to keep transactions + short. + settings: + A runtime settings object that exposes ``tibber_api_token`` and + ``tibber_home_id`` attributes (typically a ``Settings`` or merged + runtime-settings instance from ``build_runtime_settings``). + + Returns + ------- + int + Number of rows upserted (inserted or updated). Returns 0 for a no-op. + + Notes + ----- + - No-op (returns 0) when: + * No active contract exists with ``kind="tibber"``, OR + * ``settings.tibber_api_token`` is empty or whitespace. + - Exceptions from the Tibber client are *not* caught here; they propagate to + the caller (the scheduled job wrapper swallows them and logs). + """ + token: str = getattr(settings, "tibber_api_token", "") or "" + if not token.strip(): + logger.debug("refresh_prices: tibber_api_token is empty — no-op") + return 0 + + if not _active_tibber_contract_exists(session): + logger.debug("refresh_prices: no active tibber contract — no-op") + return 0 + + home_id: str = getattr(settings, "tibber_home_id", "") or "" + home_id_or_none: str | None = home_id.strip() or None + + logger.info("refresh_prices: fetching Tibber price range (home_id=%r)", home_id_or_none) + + # May raise TibberError or TibberAuthError — let them propagate. + price_points = fetch_price_range(token, home_id_or_none) + + if not price_points: + logger.info("refresh_prices: API returned zero price points — nothing to upsert") + return 0 + + fetched_at = datetime.now(UTC) + upserted = 0 + + for point in price_points: + existing = session.execute( + select(TibberPrice).where(TibberPrice.starts_at == point.starts_at) + ).scalar_one_or_none() + + if existing is not None: + # Update in-place — price fields may change (e.g. Tibber corrects a + # forecast), but starts_at and resolution stay the same. + existing.total = point.total + existing.energy = point.energy + existing.tax = point.tax + existing.level = point.level + existing.currency = point.currency + existing.fetched_at = fetched_at + else: + session.add( + TibberPrice( + starts_at=point.starts_at, + resolution=point.resolution, + total=point.total, + energy=point.energy, + tax=point.tax, + level=point.level, + currency=point.currency, + fetched_at=fetched_at, + ) + ) + + upserted += 1 + + session.commit() + + logger.info("refresh_prices: upserted %d price points", upserted) + return upserted diff --git a/docs/design/m6-tibber-dynamic-energy.md b/docs/design/m6-tibber-dynamic-energy.md index b0a5b54..2616a17 100644 --- a/docs/design/m6-tibber-dynamic-energy.md +++ b/docs/design/m6-tibber-dynamic-energy.md @@ -347,7 +347,7 @@ Phase D(API + 前端) - **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 `validate_values`;路径键用合同 id。 ### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test -- **Status**: `todo` · **Depends**: M6-T01, M6-T02, M6-T03 +- **Status**: `done` · **Depends**: M6-T01, M6-T02, M6-T03 - **Context**: httpx 拉 15min 价 upsert `tibber_price`;启动+每日抓(仅 active=tibber);连接测试。 - **Files**: `create app/integrations/tibber/__init__.py`、`tibber/client.py`、`app/services/tibber_prices.py`;`modify app/main.py`(抓价 job);`create tests/test_tibber_client.py`、`tests/test_tibber_prices.py` - **Steps**: diff --git a/tests/test_tibber_client.py b/tests/test_tibber_client.py new file mode 100644 index 0000000..5ea1886 --- /dev/null +++ b/tests/test_tibber_client.py @@ -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) diff --git a/tests/test_tibber_prices.py b/tests/test_tibber_prices.py new file mode 100644 index 0000000..5d65def --- /dev/null +++ b/tests/test_tibber_prices.py @@ -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