M8-T06: add meter source management API
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
"""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_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()
|
||||
Reference in New Issue
Block a user