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