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,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
|
||||||
|
"""
|
||||||
@@ -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")
|
||||||
+39
@@ -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.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.modbus_poll import poll_all_enabled_devices, BASE_POLL_TICK_SECONDS
|
||||||
from app.services.ha_discovery import publish_discovery, publish_states
|
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
|
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -63,6 +64,33 @@ def _run_scheduled_modbus_poll() -> None:
|
|||||||
session.close()
|
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:
|
def _run_scheduled_ha_state_publish() -> None:
|
||||||
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
|
"""Periodic job: publish discovery configs + state + availability for all enabled exposed entities.
|
||||||
|
|
||||||
@@ -148,6 +176,17 @@ async def lifespan(_: FastAPI):
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
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()
|
scheduler.start()
|
||||||
|
|
||||||
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
# MQTT: connect using DB-merged runtime settings so broker configured via UI
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -347,7 +347,7 @@ Phase D(API + 前端)
|
|||||||
- **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 `validate_values`;路径键用合同 id。
|
- **Reviewer checklist**: 版本只增不改(不覆盖旧版本);激活互斥;删除受 FK RESTRICT(有版本/有费用记录不可裸删);数值校验走 T03 `validate_values`;路径键用合同 id。
|
||||||
|
|
||||||
### M6-T05 — Tibber 客户端 + 抓价 service + 调度 + tibber/test
|
### 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);连接测试。
|
- **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`
|
- **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**:
|
- **Steps**:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user