"""Contract tests for M8 source/channel/binding management routes.""" from __future__ import annotations from datetime import UTC, datetime from unittest.mock import patch from fastapi.testclient import TestClient from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from app.models.energy import DsmrReading, Meter from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel _CSRF = "test-csrf-token" def _login(client: TestClient) -> None: assert client.post("/api/auth/login", json={"username": "admin", "password": "test-password"}).status_code == 200 def _client(auth_database): from app.main import create_app engine = create_engine(auth_database["app_url"], connect_args={"check_same_thread": False}) return TestClient(create_app()), engine def _create_source(client: TestClient, *, config: dict | None = None) -> dict: response = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ "name": "Synthetic DSMR", "kind": "dsmr_mqtt", "config": config or {}, }) assert response.status_code == 201 return response.json() def _add_channel(engine, source_uuid: str, *, key: str = "electricity") -> str: with Session(engine) as session: source = session.execute(select(MeterSource).where(MeterSource.uuid == source_uuid)).scalar_one() now = datetime.now(UTC) channel = MeterSourceChannel( source_id=source.id, channel_key=key, label="Electricity", unit="kWh", created_at=now, updated_at=now, ) session.add(channel) session.commit() return channel.uuid def test_source_profiles_and_crud_mask_secrets(auth_database): client, engine = _client(auth_database) with client: assert client.get("/api/energy/source-profiles").status_code == 401 _login(client) profiles = client.get("/api/energy/source-profiles") assert profiles.status_code == 200 assert {item["kind"] for item in profiles.json()["items"]} == {"dsmr_mqtt", "warmtelink_serial"} create = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ "name": "Synthetic DSMR", "kind": "dsmr_mqtt", "config": {"username": "user", "password": "not-for-api"}, }) assert create.status_code == 201 source = create.json() assert source["config"]["username"] == "" assert source["config"]["password"] == "" assert "not-for-api" not in str(source) patch = client.patch(f"/api/energy/sources/{source['uuid']}", headers={"X-CSRF-Token": _CSRF}, json={"config": {"password": ""}}) assert patch.status_code == 200 assert patch.json()["config"]["password"] == "" with Session(engine) as session: stored = session.execute( select(MeterSource).where(MeterSource.uuid == source["uuid"]) ).scalar_one() assert stored.config["password"] == "not-for-api" assert client.post(f"/api/energy/sources/{source['uuid']}/discover", headers={"X-CSRF-Token": _CSRF}).status_code == 200 assert client.delete(f"/api/energy/sources/{source['uuid']}", headers={"X-CSRF-Token": _CSRF}).status_code == 204 engine.dispose() def test_serial_source_reconciles_only_after_commit(auth_database, monkeypatch): """Creation and mutation trigger the worker manager after a successful commit.""" from app.api.routes.api import meter_sources calls = [] monkeypatch.setattr(meter_sources.warmtelink_worker_manager, "reconcile", lambda: calls.append(True)) client, engine = _client(auth_database) with client: _login(client) baseline = len(calls) response = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ "name": "Serial", "kind": "warmtelink_serial", "config": {"path": "/dev/ttyUSB0"}, }) assert response.status_code == 201 source_uuid = response.json()["uuid"] assert len(calls) == baseline + 1 assert client.patch(f"/api/energy/sources/{source_uuid}", headers={"X-CSRF-Token": _CSRF}, json={"enabled": False}).status_code == 200 assert len(calls) == baseline + 2 engine.dispose() def test_serial_source_commit_is_not_reported_as_failed_when_reconcile_raises(auth_database, monkeypatch): from app.api.routes.api import meter_sources client, engine = _client(auth_database) with client: _login(client) monkeypatch.setattr( meter_sources.warmtelink_worker_manager, "reconcile", lambda: (_ for _ in ()).throw(RuntimeError()), ) response = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ "name": "Serial", "kind": "warmtelink_serial", "config": {"path": "/dev/ttyUSB0"}, }) assert response.status_code == 201 engine.dispose() def test_binding_routes_and_atomic_meter_declaration(auth_database): client, engine = _client(auth_database) with client: _login(client) source_response = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ "name": "Synthetic DSMR", "kind": "dsmr_mqtt", "config": {}, }) source_uuid = source_response.json()["uuid"] with Session(engine) as session: source = session.query(MeterSource).filter_by(uuid=source_uuid).one() now = datetime.now(UTC) channel = MeterSourceChannel( source_id=source.id, channel_key="electricity", label="Electricity", unit="kWh", created_at=now, updated_at=now, ) session.add(channel) session.commit() channel_uuid = channel.uuid declaration = { "label": "Bound meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial", "commodity": "electricity", "source_channel_uuid": channel_uuid, } created = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json=declaration) assert created.status_code == 201 assert created.json()["bindings"][0]["source_channel_uuid"] == channel_uuid meter_id = created.json()["id"] assert client.get(f"/api/energy/meters/{meter_id}/bindings").json()["total"] == 1 assert client.get(f"/api/energy/sources/{source_uuid}/channels").json()["items"][0]["binding_count"] == 1 invalid = dict(declaration, label="Must roll back", started_at="2030-02-01T00:00:00Z", source_channel_uuid="missing-channel") assert client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json=invalid).status_code == 404 assert client.get("/api/energy/meters").json()["total"] == 1 with Session(engine) as session: original = session.get(Meter, meter_id) assert original is not None assert original.ended_at is None assert session.execute(select(Meter).where(Meter.label == "Must roll back")).scalar_one_or_none() is None assert session.execute(select(MeterSourceBinding).where(MeterSourceBinding.meter_id == meter_id)).scalars().all() engine.dispose() def test_source_and_binding_error_contracts_csrf_timezone_and_dsmr_compatibility(auth_database, monkeypatch): """Exercise the public error boundary without opening serial or MQTT I/O.""" from zoneinfo import ZoneInfo from app.services import timezone as timezone_service monkeypatch.setattr(timezone_service, "local_tz", lambda: ZoneInfo("Europe/Amsterdam")) client, engine = _client(auth_database) with client: # All management reads require a session and mutations require CSRF. assert client.get("/api/energy/commodities").status_code == 401 _login(client) assert client.post("/api/energy/sources", json={ "name": "No CSRF", "kind": "dsmr_mqtt", "config": {}, }).status_code == 403 assert client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ "name": "Invalid", "kind": "dsmr_mqtt", "config": {"unexpected": True}, }).status_code == 422 assert client.get("/api/energy/sources/does-not-exist").status_code == 404 source = client.post("/api/energy/sources", headers={"X-CSRF-Token": _CSRF}, json={ "name": "DSMR source", "kind": "dsmr_mqtt", "config": {"password": "stored-secret"}, }) assert source.status_code == 201 source_uuid = source.json()["uuid"] assert "stored-secret" not in client.get(f"/api/energy/sources/{source_uuid}").text assert client.get("/api/energy/commodities").json()["items"] == [ {"key": "electricity", "unit": "kWh", "capabilities": ["meter", "binding", "cost"]}, {"key": "heating", "unit": "GJ", "capabilities": ["meter", "binding"]}, {"key": "hot_water", "unit": "m³", "capabilities": ["meter", "binding"]}, ] with Session(engine) as session: source_model = session.query(MeterSource).filter_by(uuid=source_uuid).one() now = datetime.now(UTC) channel = MeterSourceChannel( source_id=source_model.id, channel_key="electricity", label="Electricity", unit="kWh", created_at=now, updated_at=now, ) session.add(channel) session.add(DsmrReading( meter_source_id=source_model.id, telegram_id=7, recorded_at=now, payload={"compatibility": "latest"}, )) session.commit() channel_uuid = channel.uuid # Retained channels prohibit deletion; there is no cascade escape hatch. assert client.delete(f"/api/energy/sources/{source_uuid}", headers={"X-CSRF-Token": _CSRF}).status_code == 409 assert client.get(f"/api/energy/sources/{source_uuid}/channels/not-a-channel/readings").status_code == 404 readings = client.get(f"/api/energy/sources/{source_uuid}/channels/{channel_uuid}/readings") assert readings.status_code == 200 assert readings.json()["total"] == 1 assert "telegram_id" not in readings.text latest = client.get("/api/energy/dsmr/latest") assert latest.status_code == 200 assert latest.json()["payload"] == {"compatibility": "latest"} # This declaration is deliberately retroactive for timezone coverage; # mock the billing sweep so the API contract test stays bounded. with patch("app.api.routes.api.meters.recompute_range", return_value=0): meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={ "label": "Local-time meter", "started_at": "2025-01-02T00:00:00", "reason": "initial", }) assert meter.status_code == 201 meter_id = meter.json()["id"] binding = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ "source_channel_uuid": channel_uuid, "started_at": "2025-01-02T00:00:00", }) assert binding.status_code == 201 binding_body = binding.json() localized_start = datetime.fromisoformat(binding_body["started_at"].replace("Z", "+00:00")) # SQLite returns UTC columns without tzinfo; retain the UTC clock instant # regardless of that transport detail. assert localized_start.replace(tzinfo=None) == datetime(2025, 1, 1, 23, 0, 0) assert client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ "source_channel_uuid": channel_uuid, "started_at": "2025-01-02T00:00:00", }).status_code == 422 assert client.patch(f"/api/energy/bindings/{binding_body['uuid']}", headers={"X-CSRF-Token": _CSRF}, json={ "ended_at": "2025-01-02T00:00:00", }).status_code == 422 assert client.patch(f"/api/energy/bindings/{binding_body['uuid']}", headers={"X-CSRF-Token": _CSRF}, json={ "ended_at": "2025-01-03T00:00:00", }).status_code == 200 assert client.patch("/api/energy/bindings/not-a-binding", headers={"X-CSRF-Token": _CSRF}, json={ "ended_at": "2025-01-03T00:00:00", }).status_code == 404 engine.dispose() def test_management_reads_require_auth_and_mutations_require_csrf(auth_database): """Every M8-T06 management route enforces the session/CSRF contract.""" client, engine = _client(auth_database) with client: for path in ( "/api/energy/source-profiles", "/api/energy/commodities", "/api/energy/sources", "/api/energy/sources/missing", "/api/energy/sources/missing/channels", "/api/energy/sources/missing/channels/missing/readings", "/api/energy/meters/1/bindings", ): assert client.get(path).status_code == 401 _login(client) assert client.post("/api/energy/sources", json={ "name": "No CSRF", "kind": "dsmr_mqtt", "config": {}, }).status_code == 403 source = _create_source(client) channel_uuid = _add_channel(engine, source["uuid"]) meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={ "label": "CSRF meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial", }) assert meter.status_code == 201 meter_id = meter.json()["id"] binding = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ "source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z", }) assert binding.status_code == 201 assert client.patch(f"/api/energy/sources/{source['uuid']}", json={"name": "blocked"}).status_code == 403 assert client.delete(f"/api/energy/sources/{source['uuid']}").status_code == 403 assert client.post(f"/api/energy/sources/{source['uuid']}/discover").status_code == 403 assert client.post(f"/api/energy/meters/{meter_id}/bindings", json={ "source_channel_uuid": channel_uuid, "started_at": "2031-01-01T00:00:00Z", }).status_code == 403 assert client.patch(f"/api/energy/bindings/{binding.json()['uuid']}", json={}).status_code == 403 engine.dispose() def test_source_channel_binding_response_contract_and_discover_capabilities(auth_database): client, engine = _client(auth_database) with client: _login(client) source = _create_source(client, config={"username": "private-user", "password": "private-secret"}) channel_uuid = _add_channel(engine, source["uuid"]) listed = client.get("/api/energy/sources") assert listed.status_code == 200 assert listed.json()["total"] >= 1 source_item = next(item for item in listed.json()["items"] if item["uuid"] == source["uuid"]) assert source_item["uuid"] == source["uuid"] detail = client.get(f"/api/energy/sources/{source['uuid']}") assert detail.status_code == 200 assert detail.json()["uuid"] == source["uuid"] for body in (listed.json(), detail.json()): rendered = str(body) assert "private-secret" not in rendered assert "channel_key" not in rendered assert "fingerprint" not in rendered discovered = client.post(f"/api/energy/sources/{source['uuid']}/discover", headers={"X-CSRF-Token": _CSRF}) assert discovered.status_code == 200 assert discovered.json() == { "requested": False, "supported": True, "status": "managed_by_runtime", "detail": "This source is discovered by its runtime subscription; no connection was opened.", } channels = client.get(f"/api/energy/sources/{source['uuid']}/channels") assert channels.status_code == 200 channel = channels.json()["items"][0] assert channel["uuid"] == channel_uuid assert set(channel) == { "uuid", "label", "suggested_commodity", "unit", "device_type", "latest_value", "latest_at", "latest_quality", "binding_count", "bound_meter_ids", } meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={ "label": "Contract meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial", }) assert meter.status_code == 201 binding = client.post(f"/api/energy/meters/{meter.json()['id']}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ "source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z", }) assert binding.status_code == 201 binding_item = client.get(f"/api/energy/meters/{meter.json()['id']}/bindings").json()["items"][0] assert binding_item["uuid"] == binding.json()["uuid"] assert binding_item["source_uuid"] == source["uuid"] assert binding_item["source_channel_uuid"] == channel_uuid assert set(binding_item) == { "uuid", "meter_id", "source_channel_uuid", "source_uuid", "started_at", "ended_at", "created_at", "updated_at", } engine.dispose() def test_binding_patch_omitted_null_and_adjacent_half_open_boundaries(auth_database): client, engine = _client(auth_database) with client: _login(client) source = _create_source(client) channel_uuid = _add_channel(engine, source["uuid"]) meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={ "label": "Timeline meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial", }) assert meter.status_code == 201 meter_id = meter.json()["id"] first = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ "source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z", "ended_at": "2030-02-01T00:00:00Z", }) assert first.status_code == 201 first_uuid = first.json()["uuid"] corrected = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={ "started_at": "2030-01-02T00:00:00Z", }) assert corrected.status_code == 200 assert corrected.json()["ended_at"] == "2030-02-01T00:00:00" unchanged = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={}) assert unchanged.status_code == 200 assert unchanged.json()["ended_at"] == "2030-02-01T00:00:00" reopened = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={"ended_at": None}) assert reopened.status_code == 200 assert reopened.json()["ended_at"] is None reclosed = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={ "ended_at": "2030-02-01T00:00:00Z", }) assert reclosed.status_code == 200 adjacent = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={ "source_channel_uuid": channel_uuid, "started_at": "2030-02-01T00:00:00Z", }) assert adjacent.status_code == 201 engine.dispose()