"""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