M8-R03: hand off channel binding during meter swap

This commit is contained in:
2026-08-24 06:45:31 +02:00
parent 231c340ea6
commit 631b14e2ec
6 changed files with 669 additions and 24 deletions
+192 -1
View File
@@ -31,7 +31,7 @@ Retroactive recompute integration
from __future__ import annotations
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import pytest
@@ -40,6 +40,7 @@ from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from app.models.energy import Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
# ---------------------------------------------------------------------------
# Shared helpers
@@ -67,6 +68,43 @@ def _declare_payload(**overrides) -> dict:
return base
def _add_bound_channel(engine, *, meter_id: int, started_at: datetime) -> str:
"""Persist one test-only DSMR channel binding and return its public UUID."""
with Session(engine) as session:
source = MeterSource(
name="Test DSMR",
kind="dsmr_mqtt",
enabled=True,
config={},
status="online",
created_at=started_at,
updated_at=started_at,
)
session.add(source)
session.flush()
channel = MeterSourceChannel(
source_id=source.id,
channel_key="electricity-total",
label="Electricity total",
unit="kWh",
created_at=started_at,
updated_at=started_at,
)
session.add(channel)
session.flush()
session.add(
MeterSourceBinding(
meter_id=meter_id,
channel_id=channel.id,
started_at=started_at,
created_at=started_at,
updated_at=started_at,
)
)
session.commit()
return channel.uuid
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -252,6 +290,159 @@ def test_declare_meter_swap_closes_previous(meters_client):
assert ended_naive == t1
def test_declare_meter_swap_hands_off_previous_meter_channel_atomically(meters_client):
client, engine = meters_client
_login(client)
t0 = datetime(2024, 6, 1, tzinfo=UTC)
boundary = datetime(2025, 3, 15, 12, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
old_response = client.post(
"/api/energy/meters",
json=_declare_payload(label="Old meter", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
assert old_response.status_code == 201
old_id = old_response.json()["id"]
channel_uuid = _add_bound_channel(engine, meter_id=old_id, started_at=t0)
response = client.post(
"/api/energy/meters",
json=_declare_payload(
label="New meter",
started_at=boundary.isoformat(),
reason="meter_swap",
source_channel_uuid=channel_uuid,
),
headers={"X-CSRF-Token": _CSRF},
)
assert response.status_code == 201
new_id = response.json()["id"]
with Session(engine) as session:
bindings = session.execute(
select(MeterSourceBinding).order_by(MeterSourceBinding.id)
).scalars().all()
old_binding_ended_at = bindings[0].ended_at
if old_binding_ended_at is not None and old_binding_ended_at.tzinfo is None:
old_binding_ended_at = old_binding_ended_at.replace(tzinfo=UTC)
new_binding_started_at = bindings[1].started_at
if new_binding_started_at.tzinfo is None:
new_binding_started_at = new_binding_started_at.replace(tzinfo=UTC)
assert [(bindings[0].meter_id, old_binding_ended_at), (bindings[1].meter_id, bindings[1].ended_at)] == [
(old_id, boundary),
(new_id, None),
]
assert new_binding_started_at == boundary
def test_declare_meter_swap_rejects_other_meter_channel_and_rolls_back(meters_client):
client, engine = meters_client
_login(client)
t0 = datetime(2024, 6, 1, tzinfo=UTC)
boundary = datetime(2025, 3, 15, 12, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
old_response = client.post(
"/api/energy/meters",
json=_declare_payload(label="Old meter", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
old_id = old_response.json()["id"]
other = Meter(
label="Other meter",
commodity="electricity",
started_at=t0,
ended_at=boundary + timedelta(days=1),
reason="initial",
created_at=t0,
)
with Session(engine) as session:
session.add(other)
session.commit()
other_id = other.id
channel_uuid = _add_bound_channel(engine, meter_id=other_id, started_at=t0)
response = client.post(
"/api/energy/meters",
json=_declare_payload(
label="Rejected meter",
started_at=boundary.isoformat(),
reason="meter_swap",
source_channel_uuid=channel_uuid,
),
headers={"X-CSRF-Token": _CSRF},
)
assert response.status_code == 422
with Session(engine) as session:
assert session.execute(select(Meter).where(Meter.label == "Rejected meter")).scalar_one_or_none() is None
assert session.get(Meter, old_id).ended_at is None
binding = session.execute(select(MeterSourceBinding)).scalar_one()
assert binding.ended_at is None
def test_declare_meter_non_swap_cannot_take_previous_meter_channel(meters_client):
client, engine = meters_client
_login(client)
t0 = datetime(2024, 6, 1, tzinfo=UTC)
boundary = datetime(2025, 3, 15, 12, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
old_response = client.post(
"/api/energy/meters",
json=_declare_payload(label="Old meter", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
old_id = old_response.json()["id"]
channel_uuid = _add_bound_channel(engine, meter_id=old_id, started_at=t0)
response = client.post(
"/api/energy/meters",
json=_declare_payload(
label="Moved meter",
started_at=boundary.isoformat(),
reason="home_move",
source_channel_uuid=channel_uuid,
),
headers={"X-CSRF-Token": _CSRF},
)
assert response.status_code == 422
with Session(engine) as session:
assert session.get(Meter, old_id).ended_at is None
assert session.execute(select(MeterSourceBinding)).scalar_one().ended_at is None
def test_declare_meter_recompute_failure_rolls_back_handoff(meters_client):
client, engine = meters_client
_login(client)
t0 = datetime(2024, 6, 1, tzinfo=UTC)
boundary = datetime(2025, 3, 15, 12, tzinfo=UTC)
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
old_response = client.post(
"/api/energy/meters",
json=_declare_payload(label="Old meter", started_at=t0.isoformat()),
headers={"X-CSRF-Token": _CSRF},
)
old_id = old_response.json()["id"]
channel_uuid = _add_bound_channel(engine, meter_id=old_id, started_at=t0)
with patch("app.api.routes.api.meters.recompute_range", side_effect=RuntimeError("recompute failed")):
with pytest.raises(RuntimeError, match="recompute failed"):
client.post(
"/api/energy/meters",
json=_declare_payload(
label="Failed meter",
started_at=boundary.isoformat(),
reason="meter_swap",
source_channel_uuid=channel_uuid,
),
headers={"X-CSRF-Token": _CSRF},
)
with Session(engine) as session:
assert session.execute(select(Meter).where(Meter.label == "Failed meter")).scalar_one_or_none() is None
assert session.get(Meter, old_id).ended_at is None
assert session.execute(select(MeterSourceBinding)).scalar_one().ended_at is None
def test_declare_meter_overlap_returns_422(meters_client):
"""Declaring a meter with started_at before active meter's started_at → 422."""
client, _ = meters_client