M8-R03: hand off channel binding during meter swap
This commit is contained in:
+192
-1
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user