M8-R08: add atomic meter close and binding transfer
This commit is contained in:
+162
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user