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
+114
View File
@@ -25,7 +25,10 @@ from sqlalchemy import create_engine, event as sa_event
from sqlalchemy.orm import Session
from app.models.energy import Meter
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
from app.services.meters import (
_as_utc,
close_meter,
MeterIntervalError,
MeterOverlapError,
declare_meter,
@@ -35,6 +38,62 @@ from app.services.meters import (
)
def test_declare_and_close_reject_future_boundaries(session: Session):
future = datetime.now(UTC) + timedelta(minutes=5)
with pytest.raises(MeterIntervalError, match="future"):
declare_meter(session, label="future", started_at=future, reason="initial")
meter = _make_meter(session, started_at=datetime.now(UTC) - timedelta(hours=1))
with pytest.raises(MeterIntervalError, match="future"):
close_meter(session, meter, ended_at=future)
assert meter.ended_at is None
def test_close_and_declare_reject_boundary_before_retained_closed_binding(session: Session):
"""Lifecycle writes must not silently shorten immutable binding history."""
start = datetime.now(UTC) - timedelta(hours=3)
proposed_end = start + timedelta(hours=1)
meter = _make_meter(session, started_at=start)
binding = _make_binding(session, meter, started_at=start, ended_at=start + timedelta(hours=2))
with pytest.raises(MeterIntervalError, match="Closed binding extends"):
close_meter(session, meter, ended_at=proposed_end)
assert meter.ended_at is None
assert binding.ended_at == start + timedelta(hours=2)
with pytest.raises(MeterIntervalError, match="Closed binding extends"):
declare_meter(session, label="replacement", started_at=proposed_end, reason="meter_swap")
assert meter.ended_at is None
assert session.query(Meter).count() == 1
def test_update_rejects_future_started_at_before_mutating_other_fields(session: Session):
"""A future correction must not leak label/note changes into the Session."""
meter = _make_meter(
session,
started_at=datetime.now(UTC) - timedelta(hours=1),
label="Original",
note="Original note",
)
session.commit()
meter_id = meter.id
with pytest.raises(MeterIntervalError, match="future"):
update_meter(
session,
meter,
label="Changed",
note="Changed note",
started_at=datetime.now(UTC) + timedelta(minutes=5),
)
session.rollback()
with Session(session.bind) as observer:
unchanged = observer.get(Meter, meter_id)
assert unchanged is not None
assert unchanged.label == "Original"
assert unchanged.note == "Original note"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@@ -112,6 +171,28 @@ def _make_meter(
return m
def _make_binding(session: Session, meter: Meter, *, started_at: datetime, ended_at: datetime | None = None):
source = MeterSource(
name=f"source-{meter.id}", 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=f"channel-{meter.id}", label="Total", unit="kWh",
created_at=started_at, updated_at=started_at,
)
session.add(channel)
session.flush()
binding = MeterSourceBinding(
meter_id=meter.id, channel_id=channel.id, started_at=started_at, ended_at=ended_at,
created_at=started_at, updated_at=started_at,
)
session.add(binding)
session.flush()
return binding
# ---------------------------------------------------------------------------
# 1. meter_at — half-open interval semantics
# ---------------------------------------------------------------------------
@@ -637,3 +718,36 @@ class TestUpdateMeter:
from app.services.meters import _as_utc
fetched = session.get(Meter, m.id)
assert _as_utc(fetched.started_at) == _as_utc(earlier)
@pytest.mark.parametrize("shift", ["later", "earlier"])
def test_update_started_at_rejects_boundary_shift_that_strands_binding(self, session: Session, shift: str):
"""A correction must not create an out-of-epoch binding on either adjacent meter."""
boundary = _T0 + timedelta(days=10)
prev = _make_meter(session, started_at=_T0, ended_at=boundary, label="Prev")
current = _make_meter(session, started_at=boundary, ended_at=None, label="Current")
if shift == "later":
binding = _make_binding(session, current, started_at=boundary)
proposed = boundary + timedelta(days=1)
else:
binding = _make_binding(session, prev, started_at=_T0, ended_at=boundary)
proposed = boundary - timedelta(days=1)
session.commit()
prev_id = prev.id
current_id = current.id
binding_id = binding.id
binding_started_at = binding.started_at
binding_ended_at = binding.ended_at
with pytest.raises(MeterIntervalError, match="Binding"):
update_meter(session, current, started_at=proposed)
session.rollback()
with Session(session.bind) as observer:
assert _as_utc(observer.get(Meter, prev_id).ended_at) == boundary
assert _as_utc(observer.get(Meter, current_id).started_at) == boundary
observed_binding = observer.get(MeterSourceBinding, binding_id)
assert observed_binding is not None
assert _as_utc(observed_binding.started_at) == _as_utc(binding_started_at)
assert (
_as_utc(observed_binding.ended_at) if observed_binding.ended_at is not None else None
) == (_as_utc(binding_ended_at) if binding_ended_at is not None else None)