Files
home-automation/tests/test_api_energy.py
T
tliu93 d07a083e03
frontend / frontend (push) Failing after 5m49s
pytest / test (push) Successful in 20m30s
docker-image / build-and-push (push) Successful in 13m26s
fix(tibber): deduct verkoopvergoeding (sell_fee) from feed-in sell price
Tibber's API `total` already includes the buy-side inkoopvergoeding
(verified from production data: total = spot×1.21 + energy_tax 0.11085 +
inkoopvergoeding 0.0248). Under net metering Tibber pays back
`total − verkoopvergoeding` per returned kWh (NL: EUR 0.28 -> 0.2552), so the
two EUR 0.0248 fees do NOT cancel — the feed-in price sits 0.0248 below buy.

Model the verkoopvergoeding as a first-class, always-subtracted contract
field `energy.sell_fee` (default 0.0248) instead of folding it into
`sell_adjust`. New sell formula:

    sell = total − energy_tax − sell_fee − sell_adjust

`sell_adjust` now carries only the net-metering energy-tax refund
(= −energy_tax). Applied in both the billing strategy and the /prices
endpoint; recorded in the pricing snapshot. Frontend renders the field
automatically (dynamic profile form). Docs (references, m6) corrected to
drop the wrong "fees cancel" premise.
2026-07-20 13:50:13 +02:00

908 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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