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.
908 lines
29 KiB
Python
908 lines
29 KiB
Python
"""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
|
||
|
||
|