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
+126
View File
@@ -23,6 +23,7 @@ from app.services.meter_sources import (
SourceDeleteRestrictedError,
close_binding,
create_binding,
create_binding_for_meter_swap,
create_source,
delete_source,
upsert_discovered_channel,
@@ -178,6 +179,131 @@ def test_binding_rejects_incompatible_unit_and_close_keeps_transaction_open(sess
assert session.get(MeterSourceBinding, binding.id) is None
def test_meter_swap_hands_off_only_the_previous_meter_binding(session):
start = datetime(2026, 8, 22, tzinfo=UTC)
boundary = start + timedelta(days=1)
old_meter = _meter(session, "heating", "old")
old_meter.started_at = start
old_meter.ended_at = boundary
new_meter = Meter(
label="new",
commodity="heating",
started_at=boundary,
reason="meter_swap",
created_at=boundary,
)
session.add(new_meter)
session.flush()
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
old_binding = create_binding(
session, meter_id=old_meter.id, channel_id=channel.id, started_at=start
)
session.flush()
new_binding = create_binding_for_meter_swap(
session,
old_meter_id=old_meter.id,
new_meter_id=new_meter.id,
channel_id=channel.id,
started_at=boundary,
)
session.flush()
assert old_binding.ended_at == boundary
assert new_binding.started_at == boundary
assert new_binding.ended_at is None
def test_meter_swap_rejects_channel_owned_by_a_different_meter(session):
start = datetime(2026, 8, 22, tzinfo=UTC)
boundary = start + timedelta(days=1)
old_meter = _meter(session, "heating", "old")
new_meter = _meter(session, "heating", "new")
other_meter = _meter(session, "heating", "other")
old_meter.started_at = start
old_meter.ended_at = boundary
new_meter.started_at = boundary
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
create_binding(session, meter_id=other_meter.id, channel_id=channel.id, started_at=start)
with pytest.raises(BindingOverlapError, match="cannot be handed off"):
create_binding_for_meter_swap(
session,
old_meter_id=old_meter.id,
new_meter_id=new_meter.id,
channel_id=channel.id,
started_at=boundary,
)
def test_meter_swap_rejects_ambiguous_channel_without_closing_any_binding(session):
start = datetime(2026, 8, 22, tzinfo=UTC)
boundary = start + timedelta(days=1)
old_meter = _meter(session, "heating", "old")
old_meter.started_at = start
old_meter.ended_at = boundary
new_meter = Meter(
label="new",
commodity="heating",
started_at=boundary,
reason="meter_swap",
created_at=boundary,
)
other_meter = _meter(session, "heating", "other")
session.add(new_meter)
session.flush()
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
old_binding = create_binding(session, meter_id=old_meter.id, channel_id=channel.id, started_at=start)
session.add(
MeterSourceBinding(
meter_id=other_meter.id,
channel_id=channel.id,
started_at=start,
created_at=start,
updated_at=start,
)
)
session.flush()
with pytest.raises(BindingOverlapError, match="occupied or has an ambiguous binding"):
create_binding_for_meter_swap(
session,
old_meter_id=old_meter.id,
new_meter_id=new_meter.id,
channel_id=channel.id,
started_at=boundary,
)
assert old_binding.ended_at is None
def test_meter_swap_rejects_incompatible_channel(session):
start = datetime(2026, 8, 22, tzinfo=UTC)
boundary = start + timedelta(days=1)
old_meter = _meter(session, "heating", "old")
old_meter.started_at = start
old_meter.ended_at = boundary
new_meter = Meter(
label="new",
commodity="heating",
started_at=boundary,
reason="meter_swap",
created_at=boundary,
)
session.add(new_meter)
session.flush()
_, channel = _source_and_channel(session, "dsmr_mqtt", "kWh")
with pytest.raises(BindingValidationError, match="requires unit"):
create_binding_for_meter_swap(
session,
old_meter_id=old_meter.id,
new_meter_id=new_meter.id,
channel_id=channel.id,
started_at=boundary,
)
def test_source_delete_is_restricted_by_discovered_channel(session):
source, _ = _source_and_channel(session, "dsmr_mqtt", "kWh")
with pytest.raises(SourceDeleteRestrictedError):