M8-R08: add atomic meter close and binding transfer

This commit is contained in:
2026-08-24 18:37:46 +02:00
parent 2be4f78f8a
commit 8dc3f71aaf
15 changed files with 1667 additions and 35 deletions
+162 -14
View File
@@ -10,6 +10,7 @@ import threading
import time
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
@@ -53,6 +54,13 @@ def _add_channel(engine, source_uuid: str, *, key: str = "electricity") -> str:
return channel.uuid
@pytest.fixture(autouse=True)
def _mock_lifecycle_recompute(monkeypatch):
"""Keep lifecycle API contracts on synthetic DBs; cost engines have their own tests."""
monkeypatch.setattr("app.api.routes.api.meters.recompute_range", lambda *args, **kwargs: 0)
monkeypatch.setattr("app.api.routes.api.meter_sources.electricity_recompute_range", lambda *args, **kwargs: 0)
def test_source_profiles_and_crud_mask_secrets(auth_database):
client, engine = _client(auth_database)
with client:
@@ -268,7 +276,7 @@ def test_binding_routes_and_atomic_meter_declaration(auth_database):
channel_uuid = channel.uuid
declaration = {
"label": "Bound meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
"label": "Bound meter", "started_at": "2025-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)
@@ -278,7 +286,7 @@ def test_binding_routes_and_atomic_meter_declaration(auth_database):
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")
invalid = dict(declaration, label="Must roll back", started_at="2025-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:
@@ -403,12 +411,12 @@ def test_management_reads_require_auth_and_mutations_require_csrf(auth_database)
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",
"label": "CSRF meter", "started_at": "2025-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",
"source_channel_uuid": channel_uuid, "started_at": "2025-01-01T00:00:00Z",
})
assert binding.status_code == 201
@@ -467,11 +475,11 @@ def test_source_channel_binding_response_contract_and_discover_capabilities(auth
assert channels.json()["source_status"] == "online"
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
"label": "Contract meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
"label": "Contract meter", "started_at": "2025-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",
"source_channel_uuid": channel_uuid, "started_at": "2025-01-01T00:00:00Z",
})
assert binding.status_code == 201
binding_item = client.get(f"/api/energy/meters/{meter.json()['id']}/bindings").json()["items"][0]
@@ -492,38 +500,178 @@ def test_binding_patch_omitted_null_and_adjacent_half_open_boundaries(auth_datab
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",
"label": "Timeline meter", "started_at": "2025-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",
"source_channel_uuid": channel_uuid, "started_at": "2025-01-01T00:00:00Z",
"ended_at": "2025-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",
"started_at": "2025-01-02T00:00:00Z",
})
assert corrected.status_code == 200
assert corrected.json()["ended_at"] == "2030-02-01T00:00:00"
assert corrected.json()["ended_at"] == "2025-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"
assert unchanged.json()["ended_at"] == "2025-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",
"ended_at": "2025-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",
"source_channel_uuid": channel_uuid, "started_at": "2025-02-01T00:00:00Z",
})
assert adjacent.status_code == 201
engine.dispose()
def test_binding_create_and_meter_declare_reject_future_boundaries(auth_database):
client, engine = _client(auth_database)
with client:
_login(client)
source = _create_source(client)
channel_uuid = _add_channel(engine, source["uuid"])
future = (datetime.now(UTC) + timedelta(minutes=5)).isoformat()
assert client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
"label": "future", "started_at": future, "reason": "initial",
}).status_code == 422
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
"label": "past", "started_at": past, "reason": "initial",
})
assert meter.status_code == 201
assert client.post(f"/api/energy/meters/{meter.json()['id']}/bindings", headers={"X-CSRF-Token": _CSRF}, json={
"source_channel_uuid": channel_uuid, "started_at": future,
}).status_code == 422
engine.dispose()
def test_transfer_recovers_stranded_previous_meter_same_channel(auth_database):
"""A retained open row on a closed predecessor is recoverable in one request."""
client, engine = _client(auth_database)
with client:
_login(client)
source = _create_source(client)
channel_uuid = _add_channel(engine, source["uuid"])
start = datetime.now(UTC) - timedelta(days=2)
boundary = start + timedelta(days=1)
with Session(engine) as session:
channel = session.scalar(select(MeterSourceChannel).where(MeterSourceChannel.uuid == channel_uuid))
assert channel is not None
old = Meter(label="old", commodity="electricity", started_at=start, ended_at=boundary,
reason="meter_swap", created_at=start)
target = Meter(label="target", commodity="electricity", started_at=boundary,
reason="meter_swap", created_at=boundary)
session.add_all([old, target])
session.flush()
stranded = MeterSourceBinding(meter_id=old.id, channel_id=channel.id, started_at=start,
created_at=start, updated_at=start)
session.add(stranded)
session.commit()
target_id, stranded_uuid = target.id, stranded.uuid
response = client.post(f"/api/energy/meters/{target_id}/bindings/transfer", headers={"X-CSRF-Token": _CSRF}, json={
"from_binding_uuid": stranded_uuid, "to_source_channel_uuid": channel_uuid,
"effective_at": (boundary + timedelta(hours=2)).isoformat(),
})
assert response.status_code == 200
assert response.json()["closed_binding"]["ended_at"] is not None
assert response.json()["created_binding"]["started_at"].startswith((boundary + timedelta(hours=2)).isoformat()[:19])
engine.dispose()
def test_transfer_recovers_unique_gapped_predecessor_after_commit(auth_database, monkeypatch):
"""Recovery closes at the old epoch end and recomputes from that earliest boundary."""
from app.api.routes.api import meter_sources
client, engine = _client(auth_database)
calls: list[tuple[datetime, datetime, bool, bool]] = []
published: list[bool] = []
monkeypatch.setattr(
meter_sources, "electricity_recompute_range",
lambda _db, start, end, *, commit, strict: calls.append((start, end, commit, strict)) or 0,
)
monkeypatch.setattr(meter_sources, "_republish_after_commit", lambda _db: published.append(True))
with client:
_login(client)
source = _create_source(client)
channel_uuid = _add_channel(engine, source["uuid"])
start = datetime.now(UTC) - timedelta(days=3)
old_end = start + timedelta(days=1)
target_start = old_end + timedelta(hours=3)
effective_at = target_start + timedelta(hours=1)
with Session(engine) as session:
channel = session.scalar(select(MeterSourceChannel).where(MeterSourceChannel.uuid == channel_uuid))
assert channel is not None
old = Meter(label="old", commodity="electricity", started_at=start, ended_at=old_end,
reason="meter_swap", created_at=start)
target = Meter(label="target", commodity="electricity", started_at=target_start,
reason="initial", created_at=target_start)
session.add_all([old, target])
session.flush()
stranded = MeterSourceBinding(meter_id=old.id, channel_id=channel.id, started_at=start,
created_at=start, updated_at=start)
session.add(stranded)
session.commit()
target_id, stranded_uuid = target.id, stranded.uuid
response = client.post(
f"/api/energy/meters/{target_id}/bindings/transfer", headers={"X-CSRF-Token": _CSRF},
json={"from_binding_uuid": stranded_uuid, "to_source_channel_uuid": channel_uuid,
"effective_at": effective_at.isoformat()},
)
assert response.status_code == 200
assert response.json()["closed_binding"]["ended_at"].startswith(old_end.isoformat()[:19])
assert response.json()["created_binding"]["started_at"].startswith(effective_at.isoformat()[:19])
assert calls and calls[0][0] == old_end and calls[0][2:] == (False, True)
assert published == [True]
engine.dispose()
def test_transfer_rejects_intervening_meter_without_changing_stranded_binding(auth_database):
"""A non-predecessor recovery request is fail-closed and rolls back cleanly."""
client, engine = _client(auth_database)
with client:
_login(client)
source = _create_source(client)
channel_uuid = _add_channel(engine, source["uuid"])
start = datetime.now(UTC) - timedelta(days=4)
old_end = start + timedelta(days=1)
target_start = old_end + timedelta(days=2)
with Session(engine) as session:
channel = session.scalar(select(MeterSourceChannel).where(MeterSourceChannel.uuid == channel_uuid))
assert channel is not None
old = Meter(label="old", commodity="electricity", started_at=start, ended_at=old_end,
reason="meter_swap", created_at=start)
intervening = Meter(label="intervening", commodity="electricity", started_at=old_end,
ended_at=target_start, reason="other", created_at=old_end)
target = Meter(label="target", commodity="electricity", started_at=target_start,
reason="initial", created_at=target_start)
session.add_all([old, intervening, target])
session.flush()
stranded = MeterSourceBinding(meter_id=old.id, channel_id=channel.id, started_at=start,
created_at=start, updated_at=start)
session.add(stranded)
session.commit()
target_id, stranded_uuid, stranded_id = target.id, stranded.uuid, stranded.id
response = client.post(
f"/api/energy/meters/{target_id}/bindings/transfer", headers={"X-CSRF-Token": _CSRF},
json={"from_binding_uuid": stranded_uuid, "to_source_channel_uuid": channel_uuid,
"effective_at": (target_start + timedelta(hours=1)).isoformat()},
)
assert response.status_code == 422
with Session(engine) as observer:
binding = observer.get(MeterSourceBinding, stranded_id)
assert binding is not None and binding.ended_at is None
assert observer.scalars(select(MeterSourceBinding)).all() == [binding]
engine.dispose()
def test_warmtelink_discover_and_minute_history_are_bounded_and_private(auth_database, monkeypatch):
"""Discover delegates to the manager; readings expose accepted minute samples only."""
from app.api.routes.api import meter_sources