Files

908 lines
29 KiB
Python
Raw Permalink Normal View History

"""Tests for M6-T09: Energy data API.
Coverage
--------
GET /api/energy/prices
- unauthenticated → 401
- no active contract → 200, kind=null, points=[], tariff=null
- tibber active contract → 200, points from tibber_price, tariff=null
- manual active contract → 200, points=[], tariff with effective prices
- limit parameter caps tibber points
GET /api/energy/costs
- unauthenticated → 401
- empty DB → 200, items=[]
- returns rows ordered by period_start ascending
- limit parameter caps results
GET /api/energy/costs/summary
- unauthenticated → 401
- empty DB → 200, all-zeros (no active contract, no periods)
- returns summarize() result structure
GET /api/energy/dsmr/latest
- unauthenticated → 401
- no data → 200, found=false
- with data → 200, found=true, correct payload
POST /api/energy/costs/recompute
- unauthenticated → 401
- missing CSRF → 403
- end ≤ start → 422
- window > 366 days → 422
- valid call → 200, recomputed=int (idempotent)
POST /api/energy/tibber/test
- unauthenticated → 401
- missing CSRF → 403
- token empty → 400 config-error
- mock client success → 200 success with price data
- mock client TibberAuthError → 502 failed
- mock client TibberError → 502 failed
- token never appears in success/failed responses
Auth/CSRF matrix: all write endpoints need session + CSRF.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.integrations.tibber.client import PricePoint, TibberAuthError, TibberError
from app.models.energy import (
DsmrReading,
EnergyCostPeriod,
EnergyContract,
EnergyContractVersion,
TibberPrice,
)
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
_CSRF = "test-csrf-token"
_MANUAL_VALUES: dict[str, Any] = {
"energy": {
"buy": {"normal": 0.40, "dal": 0.30},
"sell": {"normal": 0.10, "dal": 0.10},
"energy_tax": 0.1108,
"ode": 0.0,
},
"standing": {
"network_fee": 25.0,
"management_fee": 9.87,
},
"credits": {
"heffingskorting": 600.0,
},
}
_TIBBER_VALUES: dict[str, Any] = {
"energy": {
"energy_tax": 0.1108,
"sell_adjust": 0.0,
},
"standing": {
"management_fee": 5.99,
"network_fee": 25.0,
},
"credits": {
"heffingskorting": 600.0,
},
}
def _login(client: TestClient) -> None:
resp = client.post(
"/api/auth/login",
json={"username": "admin", "password": "test-password"},
)
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
def _set_app_config(engine, key: str, value: str) -> None:
"""Upsert a key/value pair into the app_config table.
Used by tibber test helpers to inject a non-empty TIBBER_API_TOKEN without
needing to patch get_app_settings (which is shared with auth infrastructure).
"""
from sqlalchemy.orm import Session as _Session
from app.models.config import AppConfigEntry
now = datetime.now(UTC)
with _Session(engine) as session:
existing = session.execute(
__import__("sqlalchemy").select(AppConfigEntry).where(AppConfigEntry.key == key)
).scalar_one_or_none()
if existing is not None:
existing.value = value
existing.updated_at = now
else:
session.add(AppConfigEntry(key=key, value=value, updated_at=now))
session.commit()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def energy_client(auth_database):
"""TestClient + SQLAlchemy engine + FastAPI app for Energy API tests."""
from app.main import create_app
app_url = auth_database["app_url"]
engine = create_engine(app_url, connect_args={"check_same_thread": False})
fastapi_app = create_app()
with TestClient(fastapi_app) as test_client:
yield test_client, engine, fastapi_app
engine.dispose()
def _make_active_manual_contract(engine) -> tuple[EnergyContract, EnergyContractVersion]:
"""Insert an active manual EnergyContract with one version and return both."""
now = datetime.now(UTC)
with Session(engine) as session:
contract = EnergyContract(
name="Test Manual",
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 - timedelta(days=30),
effective_to=None,
values=_MANUAL_VALUES,
created_at=now,
)
session.add(version)
session.commit()
# capture ids before session closes
contract_id = contract.id
version_id = version.id
# Return plain data structs (not ORM objects tied to closed session)
class _ContractStub:
id = contract_id
class _VersionStub:
id = version_id
return _ContractStub(), _VersionStub() # type: ignore[return-value]
def _make_active_tibber_contract(engine) -> tuple[int, int]:
"""Insert an active tibber EnergyContract with one version and return (contract_id, version_id)."""
now = datetime.now(UTC)
with Session(engine) as session:
contract = EnergyContract(
name="Test Tibber",
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 - timedelta(days=30),
effective_to=None,
values=_TIBBER_VALUES,
created_at=now,
)
session.add(version)
session.commit()
return contract.id, version.id
def _make_tibber_prices(engine, count: int = 3) -> list[datetime]:
"""Insert ``count`` TibberPrice rows starting from one hour ago; return starts_at list."""
now = datetime.now(UTC).replace(second=0, microsecond=0)
base = now - timedelta(hours=1)
starts = [base + timedelta(minutes=15 * i) for i in range(count)]
with Session(engine) as session:
for s in starts:
row = TibberPrice(
starts_at=s,
resolution="QUARTER_HOURLY",
energy=0.18,
tax=0.065,
total=0.245,
level="NORMAL",
currency="EUR",
fetched_at=now,
)
session.add(row)
session.commit()
return starts
def _make_cost_periods(engine, count: int = 3, version_id: int | None = None) -> list[datetime]:
"""Insert ``count`` EnergyCostPeriod rows starting from one hour ago; return period_start list."""
now = datetime.now(UTC).replace(second=0, microsecond=0)
base = now - timedelta(hours=2)
starts = []
with Session(engine) as session:
for i in range(count):
t0 = base + timedelta(minutes=15 * i)
period = EnergyCostPeriod(
period_start=t0,
d1_kwh=0.1 * i,
d2_kwh=0.05 * i,
r1_kwh=0.0,
r2_kwh=0.0,
import_cost=0.05 * i,
export_revenue=0.0,
net_cost=0.05 * i,
currency="EUR",
pricing={"kind": "manual"},
contract_version_id=version_id,
degraded=False,
computed_at=now,
)
session.add(period)
starts.append(t0)
session.commit()
return starts
def _make_dsmr_reading(engine, recorded_at: datetime | None = None) -> datetime:
"""Insert one DsmrReading and return its recorded_at."""
now = recorded_at or datetime.now(UTC)
with Session(engine) as session:
row = DsmrReading(
recorded_at=now,
source_id=42,
payload={"electricity_delivered_1": "100.0", "test": True},
)
session.add(row)
session.commit()
return now
# ---------------------------------------------------------------------------
# GET /api/energy/prices — auth
# ---------------------------------------------------------------------------
def test_prices_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.get("/api/energy/prices")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/energy/prices — no active contract
# ---------------------------------------------------------------------------
def test_prices_no_contract_returns_empty(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.get("/api/energy/prices")
assert resp.status_code == 200
body = resp.json()
assert body["kind"] is None
assert body["points"] == []
assert body["tariff"] is None
assert "currency" in body
# ---------------------------------------------------------------------------
# GET /api/energy/prices — manual contract
# ---------------------------------------------------------------------------
def test_prices_manual_contract_returns_tariff(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
resp = client.get("/api/energy/prices")
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "manual"
assert body["currency"] == "EUR"
assert body["points"] == []
assert body["tariff"] is not None
tariff = body["tariff"]
# buy_dal = 0.30 + 0.1108 + 0.0 = 0.4108
assert abs(tariff["buy_dal"] - 0.4108) < 1e-6, f"buy_dal wrong: {tariff['buy_dal']}"
# buy_normal = 0.40 + 0.1108 + 0.0 = 0.5108
assert abs(tariff["buy_normal"] - 0.5108) < 1e-6, f"buy_normal wrong: {tariff['buy_normal']}"
# sell_dal = 0.10 (no tax added)
assert abs(tariff["sell_dal"] - 0.10) < 1e-6
# sell_normal = 0.10
assert abs(tariff["sell_normal"] - 0.10) < 1e-6
# ---------------------------------------------------------------------------
# GET /api/energy/prices — tibber contract
# ---------------------------------------------------------------------------
def test_prices_tibber_contract_returns_points(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_tibber_contract(engine)
_make_tibber_prices(engine, count=3)
# Use a wide enough window to include our test prices.
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "tibber"
assert body["tariff"] is None
assert isinstance(body["points"], list)
assert len(body["points"]) == 3
# Verify ascending order.
starts_at_list = [p["starts_at"] for p in body["points"]]
assert starts_at_list == sorted(starts_at_list)
# Check buy/sell calculations: buy=total=0.245, sell=total-energy_tax-sell_fee-sell_adjust
# (this version has no sell_fee/sell_adjust → both default to 0 at read time).
for p in body["points"]:
assert abs(p["buy"] - 0.245) < 1e-6
assert abs(p["sell"] - (0.245 - 0.1108)) < 1e-4
assert p["level"] == "NORMAL"
def test_prices_tibber_sell_reflects_sell_fee(energy_client):
"""/prices sell price deducts sell_fee (verkoopvergoeding), net-metering config."""
client, engine, _app = energy_client
_login(client)
# Net-metering version: sell_adjust = energy_tax (refund tax), sell_fee = 0.0248.
now = datetime.now(UTC)
with Session(engine) as session:
contract = EnergyContract(
name="Tibber NetMeter",
kind="tibber",
active=True,
currency="EUR",
created_at=now,
updated_at=now,
)
session.add(contract)
session.flush()
session.add(
EnergyContractVersion(
contract_id=contract.id,
effective_from=now - timedelta(days=30),
effective_to=None,
values={
"energy": {
"energy_tax": 0.1108,
"sell_fee": 0.0248,
"sell_adjust": -0.1108,
},
"standing": {"management_fee": 5.99, "network_fee": 25.0},
"credits": {"heffingskorting": 600.0},
},
created_at=now,
)
)
session.commit()
_make_tibber_prices(engine, count=3)
start = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
resp = client.get("/api/energy/prices", params={"start": start, "end": end})
assert resp.status_code == 200
body = resp.json()
assert body["kind"] == "tibber"
assert len(body["points"]) == 3
# sell = 0.245 0.1108 0.0248 (0.1108) = 0.245 0.0248 = 0.2202
for p in body["points"]:
assert abs(p["buy"] - 0.245) < 1e-6
assert abs(p["sell"] - 0.2202) < 1e-4
def test_prices_tibber_limit_caps_results(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_tibber_contract(engine)
_make_tibber_prices(engine, count=5)
start = (datetime.now(UTC) - timedelta(hours=3)).isoformat()
end = (datetime.now(UTC) + timedelta(hours=2)).isoformat()
resp = client.get(
"/api/energy/prices", params={"start": start, "end": end, "limit": 2}
)
assert resp.status_code == 200
body = resp.json()
assert len(body["points"]) <= 2
# ---------------------------------------------------------------------------
# GET /api/energy/costs — auth
# ---------------------------------------------------------------------------
def test_costs_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.get("/api/energy/costs")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/energy/costs — empty
# ---------------------------------------------------------------------------
def test_costs_empty_returns_empty(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.get("/api/energy/costs")
assert resp.status_code == 200
body = resp.json()
assert body["items"] == []
assert body["total"] == 0
# ---------------------------------------------------------------------------
# GET /api/energy/costs — with data
# ---------------------------------------------------------------------------
def test_costs_returns_sorted_ascending(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
_make_cost_periods(engine, count=3)
resp = client.get("/api/energy/costs")
assert resp.status_code == 200
body = resp.json()
assert body["total"] == 3
period_starts = [item["period_start"] for item in body["items"]]
assert period_starts == sorted(period_starts), "Items must be ascending by period_start"
def test_costs_limit_caps_results(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
_make_cost_periods(engine, count=5)
resp = client.get("/api/energy/costs?limit=2")
assert resp.status_code == 200
body = resp.json()
assert body["total"] <= 2
assert len(body["items"]) <= 2
def test_costs_schema_fields_present(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
_make_cost_periods(engine, count=1)
resp = client.get("/api/energy/costs")
assert resp.status_code == 200
item = resp.json()["items"][0]
for field in (
"period_start",
"d1_kwh", "d2_kwh", "r1_kwh", "r2_kwh",
"import_cost", "export_revenue", "net_cost",
"currency", "degraded",
):
assert field in item, f"Missing field: {field}"
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary — auth
# ---------------------------------------------------------------------------
def test_summary_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.get("/api/energy/costs/summary")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary — empty
# ---------------------------------------------------------------------------
def test_summary_empty_returns_zeros(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.get("/api/energy/costs/summary")
assert resp.status_code == 200
body = resp.json()
assert body["period_count"] == 0
assert body["metered_net"] == 0.0
assert "total_payable" in body
assert "currency" in body
# ---------------------------------------------------------------------------
# GET /api/energy/costs/summary — with data
# ---------------------------------------------------------------------------
def test_summary_returns_correct_structure(energy_client):
client, engine, _app = energy_client
_login(client)
_make_active_manual_contract(engine)
_make_cost_periods(engine, count=2)
start = (datetime.now(UTC) - timedelta(hours=3)).isoformat()
end = datetime.now(UTC).isoformat()
resp = client.get(
"/api/energy/costs/summary", params={"start": start, "end": end}
)
assert resp.status_code == 200
body = resp.json()
for field in (
"currency",
"metered_import",
"metered_export",
"metered_net",
"fixed_costs",
"credits",
"total_payable",
"period_count",
"degraded_count",
"days",
):
assert field in body, f"Missing field in summary: {field}"
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest — auth
# ---------------------------------------------------------------------------
def test_dsmr_latest_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.get("/api/energy/dsmr/latest")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest — no data
# ---------------------------------------------------------------------------
def test_dsmr_latest_no_data_returns_not_found(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.get("/api/energy/dsmr/latest")
assert resp.status_code == 200
body = resp.json()
assert body["found"] is False
assert body["recorded_at"] is None
assert body["payload"] is None
# ---------------------------------------------------------------------------
# GET /api/energy/dsmr/latest — with data
# ---------------------------------------------------------------------------
def test_dsmr_latest_returns_most_recent(energy_client):
client, engine, _app = energy_client
_login(client)
# Insert two readings; the more recent one should be returned.
older = datetime.now(UTC) - timedelta(minutes=5)
newer = datetime.now(UTC) - timedelta(seconds=30)
with Session(engine) as session:
session.add(DsmrReading(recorded_at=older, source_id=1, payload={"order": "first"}))
session.add(DsmrReading(recorded_at=newer, source_id=2, payload={"order": "second"}))
session.commit()
resp = client.get("/api/energy/dsmr/latest")
assert resp.status_code == 200
body = resp.json()
assert body["found"] is True
assert body["payload"]["order"] == "second"
assert body["recorded_at"] is not None
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute — auth / CSRF
# ---------------------------------------------------------------------------
def test_recompute_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
now = datetime.now(UTC)
resp = client.post(
"/api/energy/costs/recompute",
params={"start": now.isoformat(), "end": (now + timedelta(hours=1)).isoformat()},
)
assert resp.status_code == 401
def test_recompute_missing_csrf_returns_403(energy_client):
client, _, _app = energy_client
_login(client)
now = datetime.now(UTC)
resp = client.post(
"/api/energy/costs/recompute",
params={"start": now.isoformat(), "end": (now + timedelta(hours=1)).isoformat()},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute — validation
# ---------------------------------------------------------------------------
def test_recompute_end_before_start_returns_422(energy_client):
client, _, _app = energy_client
_login(client)
now = datetime.now(UTC)
resp = client.post(
"/api/energy/costs/recompute",
params={
"start": now.isoformat(),
"end": (now - timedelta(hours=1)).isoformat(),
},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
def test_recompute_window_too_large_returns_422(energy_client):
client, _, _app = energy_client
_login(client)
now = datetime.now(UTC)
resp = client.post(
"/api/energy/costs/recompute",
params={
"start": (now - timedelta(days=400)).isoformat(),
"end": now.isoformat(),
},
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 422
# ---------------------------------------------------------------------------
# POST /api/energy/costs/recompute — success (idempotent)
# ---------------------------------------------------------------------------
def test_recompute_returns_count(energy_client):
client, _, _app = energy_client
_login(client)
# A window with no data is fine — recompute returns 0 but is not an error.
now = datetime.now(UTC)
params = {
"start": (now - timedelta(hours=1)).isoformat(),
"end": now.isoformat(),
}
resp = client.post(
"/api/energy/costs/recompute",
params=params,
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert "recomputed" in body
assert isinstance(body["recomputed"], int)
def test_recompute_is_idempotent(energy_client):
"""Calling recompute twice with the same window must produce the same result."""
client, _, _app = energy_client
_login(client)
now = datetime.now(UTC)
params = {
"start": (now - timedelta(hours=1)).isoformat(),
"end": now.isoformat(),
}
headers = {"X-CSRF-Token": _CSRF}
resp1 = client.post("/api/energy/costs/recompute", params=params, headers=headers)
resp2 = client.post("/api/energy/costs/recompute", params=params, headers=headers)
assert resp1.status_code == 200
assert resp2.status_code == 200
assert resp1.json()["recomputed"] == resp2.json()["recomputed"]
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — auth / CSRF
# ---------------------------------------------------------------------------
def test_tibber_test_unauthenticated_returns_401(energy_client):
client, _, _app = energy_client
resp = client.post("/api/energy/tibber/test")
assert resp.status_code == 401
def test_tibber_test_missing_csrf_returns_403(energy_client):
client, _, _app = energy_client
_login(client)
resp = client.post("/api/energy/tibber/test")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — token empty → 400 config-error
# ---------------------------------------------------------------------------
def test_tibber_test_no_token_returns_config_error(energy_client):
"""When tibber_api_token is empty, expect 400 config-error regardless of CSRF."""
client, _, _app = energy_client
_login(client)
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
# Token is empty by default (no config in test DB).
assert resp.status_code == 400
body = resp.json()
assert body["result"] == "config-error"
assert "message" in body
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — mock success
# ---------------------------------------------------------------------------
def test_tibber_test_success(energy_client):
"""Token stored in DB app_config + mocked HTTP → 200 success with price data."""
client, engine, _app = energy_client
_login(client)
# Set TIBBER_API_TOKEN in the app_config table so get_app_settings returns it.
_set_app_config(engine, "TIBBER_API_TOKEN", "fake-tibber-token")
mock_price = PricePoint(
starts_at=datetime(2026, 6, 23, 14, 0, 0, tzinfo=UTC),
total=0.245,
energy=0.18,
tax=0.065,
currency="EUR",
level="NORMAL",
resolution="QUARTER_HOURLY",
)
with patch(
"app.api.routes.api.energy.fetch_current_price",
return_value=mock_price,
):
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
body = resp.json()
assert body["result"] == "success"
assert body["price"] is not None
assert body["price"]["total"] == 0.245
assert body["price"]["currency"] == "EUR"
assert body["price"]["level"] == "NORMAL"
# Token must NOT appear anywhere in the response.
assert "fake-tibber-token" not in str(body)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — mock TibberAuthError → 502
# ---------------------------------------------------------------------------
def test_tibber_test_auth_error_returns_502(energy_client):
"""Bad token stored in DB + mocked TibberAuthError → 502 failed."""
client, engine, _app = energy_client
_login(client)
_set_app_config(engine, "TIBBER_API_TOKEN", "bad-tibber-token")
with patch(
"app.api.routes.api.energy.fetch_current_price",
side_effect=TibberAuthError("auth failed"),
):
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 502
body = resp.json()
assert body["result"] == "failed"
assert "bad-tibber-token" not in str(body)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — mock TibberError → 502
# ---------------------------------------------------------------------------
def test_tibber_test_network_error_returns_502(energy_client):
"""Token in DB + mocked TibberError (network timeout) → 502 failed."""
client, engine, _app = energy_client
_login(client)
_set_app_config(engine, "TIBBER_API_TOKEN", "some-tibber-token")
with patch(
"app.api.routes.api.energy.fetch_current_price",
side_effect=TibberError("network timeout"),
):
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 502
body = resp.json()
assert body["result"] == "failed"
assert "some-tibber-token" not in str(body)
# ---------------------------------------------------------------------------
# POST /api/energy/tibber/test — token never in response (extra check)
# ---------------------------------------------------------------------------
def test_tibber_test_token_not_in_response(energy_client):
"""Even in the success response, the Tibber token must not appear."""
client, engine, _app = energy_client
_login(client)
secret_token = "super-secret-tibber-token-xyz"
_set_app_config(engine, "TIBBER_API_TOKEN", secret_token)
mock_price = PricePoint(
starts_at=datetime(2026, 6, 23, 14, 0, 0, tzinfo=UTC),
total=0.30,
energy=0.22,
tax=0.08,
currency="EUR",
level="CHEAP",
resolution="QUARTER_HOURLY",
)
with patch(
"app.api.routes.api.energy.fetch_current_price",
return_value=mock_price,
):
resp = client.post(
"/api/energy/tibber/test",
headers={"X-CSRF-Token": _CSRF},
)
assert resp.status_code == 200
assert secret_token not in resp.text