384 lines
19 KiB
Python
384 lines
19 KiB
Python
"""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()
|