M6-T09: add Energy data API (prices/costs/summary/dsmr-latest/recompute/tibber-test)
- routes/api/energy.py + schemas/energy.py: GET prices (tibber points or manual tariff, buy/sell consistent with the pricing strategies), GET costs (time window + limit, ascending), GET costs/summary (summarize passthrough), GET dsmr/latest, POST costs/recompute (idempotent, <=366d guard, CSRF), POST tibber/test (three-state, token never echoed, CSRF). - main.py registers router; OpenAPI re-exported; tests for all endpoints.
This commit is contained in:
@@ -0,0 +1,855 @@
|
||||
"""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_adjust=0.245-0.1108-0.0
|
||||
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_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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user