M8-R08: add atomic meter close and binding transfer
This commit is contained in:
+436
-7
@@ -31,7 +31,8 @@ Retroactive recompute integration
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -39,7 +40,7 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, event, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.energy import EnergyCostPeriod, Meter
|
||||
from app.models.energy import EnergyCostPeriod, Meter, MeterCostPeriod
|
||||
from app.models.meter_source import MeterSource, MeterSourceBinding, MeterSourceChannel
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -68,8 +69,15 @@ 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."""
|
||||
def _add_bound_channel(
|
||||
engine,
|
||||
*,
|
||||
meter_id: int,
|
||||
started_at: datetime,
|
||||
ended_at: datetime | None = None,
|
||||
unit: str = "kWh",
|
||||
) -> str:
|
||||
"""Persist one test-only channel binding and return its public UUID."""
|
||||
with Session(engine) as session:
|
||||
source = MeterSource(
|
||||
name="Test DSMR",
|
||||
@@ -84,9 +92,9 @@ def _add_bound_channel(engine, *, meter_id: int, started_at: datetime) -> str:
|
||||
session.flush()
|
||||
channel = MeterSourceChannel(
|
||||
source_id=source.id,
|
||||
channel_key="electricity-total",
|
||||
label="Electricity total",
|
||||
unit="kWh",
|
||||
channel_key=f"test-total-{meter_id}",
|
||||
label="Test total",
|
||||
unit=unit,
|
||||
created_at=started_at,
|
||||
updated_at=started_at,
|
||||
)
|
||||
@@ -97,6 +105,7 @@ def _add_bound_channel(engine, *, meter_id: int, started_at: datetime) -> str:
|
||||
meter_id=meter_id,
|
||||
channel_id=channel.id,
|
||||
started_at=started_at,
|
||||
ended_at=ended_at,
|
||||
created_at=started_at,
|
||||
updated_at=started_at,
|
||||
)
|
||||
@@ -595,6 +604,7 @@ def test_declare_meter_retroactive_triggers_recompute(meters_client):
|
||||
# recompute_range should have been called with start == t_past
|
||||
assert mock_recompute.called
|
||||
assert mock_recompute.call_args.kwargs["commit"] is False
|
||||
assert mock_recompute.call_args.kwargs["strict"] is True
|
||||
call_args = mock_recompute.call_args
|
||||
recompute_start = call_args[0][1] # positional arg index 1 (session is 0)
|
||||
# Normalise for comparison
|
||||
@@ -735,6 +745,7 @@ def test_patch_meter_started_at_retroactive_triggers_recompute(meters_client):
|
||||
# recompute should be triggered
|
||||
assert mock_recompute.called
|
||||
assert mock_recompute.call_args.kwargs["commit"] is False
|
||||
assert mock_recompute.call_args.kwargs["strict"] is True
|
||||
call_args = mock_recompute.call_args
|
||||
recompute_start = call_args[0][1]
|
||||
if recompute_start.tzinfo is None:
|
||||
@@ -776,6 +787,59 @@ def test_patch_meter_started_at_interval_violation_returns_422(meters_client):
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shift", ["later", "earlier"])
|
||||
def test_patch_meter_started_at_rejects_boundary_shift_that_strands_binding(
|
||||
meters_client, mock_publish_discovery, shift
|
||||
):
|
||||
"""Rejected boundary shifts leave adjacent meters/bindings untouched and emit no side effects."""
|
||||
client, engine = meters_client
|
||||
_login(client)
|
||||
t0 = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
boundary = datetime(2025, 1, 1, tzinfo=UTC)
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||
meter_a = client.post(
|
||||
"/api/energy/meters", json=_declare_payload(label="A", started_at=t0.isoformat()),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
).json()
|
||||
meter_b = client.post(
|
||||
"/api/energy/meters", json=_declare_payload(
|
||||
label="B", started_at=boundary.isoformat(), reason="meter_swap"
|
||||
), headers={"X-CSRF-Token": _CSRF},
|
||||
).json()
|
||||
if shift == "later":
|
||||
_add_bound_channel(engine, meter_id=meter_b["id"], started_at=boundary)
|
||||
proposed = boundary + timedelta(days=1)
|
||||
else:
|
||||
_add_bound_channel(engine, meter_id=meter_a["id"], started_at=t0)
|
||||
with Session(engine) as session:
|
||||
binding = session.scalar(select(MeterSourceBinding))
|
||||
assert binding is not None
|
||||
binding.ended_at = boundary
|
||||
session.commit()
|
||||
proposed = boundary - timedelta(days=1)
|
||||
|
||||
mock_publish_discovery.reset_mock()
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0) as recompute:
|
||||
response = client.patch(
|
||||
f"/api/energy/meters/{meter_b['id']}", json={"started_at": proposed.isoformat()},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
recompute.assert_not_called()
|
||||
mock_publish_discovery.assert_not_called()
|
||||
|
||||
with Session(engine) as observer:
|
||||
assert observer.get(Meter, meter_a["id"]).ended_at.replace(tzinfo=UTC) == boundary
|
||||
assert observer.get(Meter, meter_b["id"]).started_at.replace(tzinfo=UTC) == boundary
|
||||
binding = observer.scalar(select(MeterSourceBinding))
|
||||
assert binding is not None
|
||||
if shift == "later":
|
||||
assert binding.meter_id == meter_b["id"] and binding.ended_at is None
|
||||
else:
|
||||
assert binding.meter_id == meter_a["id"]
|
||||
assert binding.ended_at.replace(tzinfo=UTC) == boundary
|
||||
|
||||
|
||||
def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
|
||||
"""PATCH that only changes label does NOT trigger recompute."""
|
||||
client, _ = meters_client
|
||||
@@ -801,6 +865,201 @@ def test_patch_meter_no_recompute_when_started_at_not_changed(meters_client):
|
||||
assert not mock_recompute.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize("representation", ["aware_utc", "naive_local"])
|
||||
def test_patch_meter_rejects_future_started_at_before_any_side_effect(
|
||||
meters_client, mock_publish_discovery, monkeypatch, representation
|
||||
):
|
||||
"""Future aware and local-naive starts leave all persisted state untouched."""
|
||||
client, engine = meters_client
|
||||
_login(client)
|
||||
started = datetime.now(UTC) - timedelta(hours=2)
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||
declared = client.post(
|
||||
"/api/energy/meters",
|
||||
json=_declare_payload(
|
||||
label="Original", note="Original note", started_at=started.isoformat()
|
||||
),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
meter_id = declared.json()["id"]
|
||||
mock_publish_discovery.reset_mock()
|
||||
future = datetime.now(UTC) + timedelta(hours=2)
|
||||
if representation == "aware_utc":
|
||||
proposed = future.isoformat()
|
||||
else:
|
||||
monkeypatch.setattr(
|
||||
"app.services.timezone.local_tz", lambda: timezone(timedelta(hours=2))
|
||||
)
|
||||
proposed = (future + timedelta(hours=2)).replace(tzinfo=None).isoformat()
|
||||
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0) as recompute:
|
||||
response = client.patch(
|
||||
f"/api/energy/meters/{meter_id}",
|
||||
json={"label": "Changed", "note": "Changed note", "started_at": proposed},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
recompute.assert_not_called()
|
||||
mock_publish_discovery.assert_not_called()
|
||||
with Session(engine) as observer:
|
||||
meter = observer.get(Meter, meter_id)
|
||||
assert meter is not None
|
||||
assert meter.label == "Original"
|
||||
assert meter.note == "Original note"
|
||||
assert meter.started_at.replace(tzinfo=UTC) == started
|
||||
|
||||
|
||||
@pytest.mark.parametrize("commodity", ["heating", "hot_water"])
|
||||
def test_patch_thermal_started_at_uses_thermal_recompute(meters_client, monkeypatch, commodity):
|
||||
"""A successful thermal correction never routes through electricity recompute."""
|
||||
from app.services import meter_cost
|
||||
|
||||
client, _ = meters_client
|
||||
_login(client)
|
||||
started = datetime.now(UTC) - timedelta(hours=3)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
meter_cost,
|
||||
"recompute_range",
|
||||
lambda db, start, end, *, commit: calls.append((start, end, commit)) or 0,
|
||||
)
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0) as electricity:
|
||||
declared = client.post(
|
||||
"/api/energy/meters",
|
||||
json=_declare_payload(
|
||||
commodity=commodity,
|
||||
label=f"{commodity} meter",
|
||||
started_at=started.isoformat(),
|
||||
),
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
assert declared.status_code == 201
|
||||
calls.clear()
|
||||
electricity.reset_mock()
|
||||
response = client.patch(
|
||||
f"/api/energy/meters/{declared.json()['id']}",
|
||||
json={"started_at": (started + timedelta(minutes=30)).isoformat()},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(calls) == 1
|
||||
assert calls[0][2] is False
|
||||
electricity.assert_not_called()
|
||||
|
||||
|
||||
def _create_adjacent_thermal_patch_state(engine, commodity: str):
|
||||
"""Create an editable thermal boundary with two untouched bindings."""
|
||||
old_start = datetime.now(UTC) - timedelta(hours=5)
|
||||
boundary = old_start + timedelta(hours=1)
|
||||
shifted = boundary + timedelta(minutes=30)
|
||||
current_binding_start = shifted + timedelta(minutes=30)
|
||||
with Session(engine) as session:
|
||||
previous = Meter(
|
||||
label="Previous thermal meter",
|
||||
commodity=commodity,
|
||||
started_at=old_start,
|
||||
ended_at=boundary,
|
||||
reason="initial",
|
||||
created_at=old_start,
|
||||
)
|
||||
current = Meter(
|
||||
label="Current thermal meter",
|
||||
commodity=commodity,
|
||||
started_at=boundary,
|
||||
reason="meter_swap",
|
||||
created_at=boundary,
|
||||
)
|
||||
session.add_all([previous, current])
|
||||
session.commit()
|
||||
previous_id, current_id = previous.id, current.id
|
||||
unit = {"heating": "GJ", "hot_water": "m³"}[commodity]
|
||||
_add_bound_channel(
|
||||
engine, meter_id=previous_id, started_at=old_start, ended_at=boundary, unit=unit
|
||||
)
|
||||
_add_bound_channel(engine, meter_id=current_id, started_at=current_binding_start, unit=unit)
|
||||
return previous_id, current_id, boundary, shifted, current_binding_start
|
||||
|
||||
|
||||
@pytest.mark.parametrize("commodity", ["heating", "hot_water"])
|
||||
@pytest.mark.parametrize("failure", ["recompute", "flush", "commit"])
|
||||
def test_patch_thermal_failure_rolls_back_lifecycle_and_cost_state(
|
||||
meters_client, mock_publish_discovery, monkeypatch, commodity, failure
|
||||
):
|
||||
"""Thermal PATCH failures roll back meters, bindings, cost writes, and HA."""
|
||||
from app.services import meter_cost
|
||||
|
||||
client, engine = meters_client
|
||||
_login(client)
|
||||
previous_id, current_id, boundary, shifted, binding_start = _create_adjacent_thermal_patch_state(
|
||||
engine, commodity
|
||||
)
|
||||
|
||||
def recompute_with_uncommitted_cost(db, start, end, *, commit):
|
||||
assert commit is False
|
||||
db.add(
|
||||
MeterCostPeriod(
|
||||
commodity=commodity,
|
||||
period_start=shifted,
|
||||
period_end=shifted + timedelta(minutes=15),
|
||||
quantity=Decimal("0"),
|
||||
cost=Decimal("0"),
|
||||
currency="EUR",
|
||||
cost_breakdown={},
|
||||
pricing_snapshot={},
|
||||
quality="invalid",
|
||||
degraded=True,
|
||||
degraded_reason="test rollback",
|
||||
created_at=shifted,
|
||||
updated_at=shifted,
|
||||
)
|
||||
)
|
||||
if failure == "recompute":
|
||||
raise RuntimeError("thermal recompute failed")
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(meter_cost, "recompute_range", recompute_with_uncommitted_cost)
|
||||
if failure == "flush":
|
||||
monkeypatch.setattr(
|
||||
Session,
|
||||
"flush",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("thermal flush failed")),
|
||||
)
|
||||
if failure == "commit":
|
||||
def fail_commit(_session: Session) -> None:
|
||||
raise RuntimeError("thermal commit failed")
|
||||
|
||||
event.listen(Session, "before_commit", fail_commit)
|
||||
|
||||
try:
|
||||
expected = f"thermal {failure} failed"
|
||||
with pytest.raises(RuntimeError, match=expected):
|
||||
client.patch(
|
||||
f"/api/energy/meters/{current_id}",
|
||||
json={"started_at": shifted.isoformat()},
|
||||
headers={"X-CSRF-Token": _CSRF},
|
||||
)
|
||||
finally:
|
||||
if failure == "commit":
|
||||
event.remove(Session, "before_commit", fail_commit)
|
||||
if failure == "flush":
|
||||
monkeypatch.undo()
|
||||
|
||||
mock_publish_discovery.assert_not_called()
|
||||
with Session(engine) as observer:
|
||||
previous = observer.get(Meter, previous_id)
|
||||
current = observer.get(Meter, current_id)
|
||||
assert previous is not None and previous.ended_at.replace(tzinfo=UTC) == boundary
|
||||
assert current is not None and current.started_at.replace(tzinfo=UTC) == boundary
|
||||
bindings = observer.execute(
|
||||
select(MeterSourceBinding).order_by(MeterSourceBinding.meter_id)
|
||||
).scalars().all()
|
||||
assert bindings[0].ended_at.replace(tzinfo=UTC) == boundary
|
||||
assert bindings[1].started_at.replace(tzinfo=UTC) == binding_start
|
||||
assert observer.execute(select(MeterCostPeriod)).scalars().all() == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timeline continuity (recompute mocked to avoid slow computation over empty quarters)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -994,3 +1253,173 @@ def test_declare_meter_succeeds_when_publish_discovery_raises(meters_client):
|
||||
# The meter must be created successfully despite the discovery failure.
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["label"] == "Best Effort Meter"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("commodity", ["heating", "hot_water"])
|
||||
def test_thermal_declare_and_close_use_meter_cost_recompute(meters_client, monkeypatch, commodity):
|
||||
"""Thermal lifecycle routes use the meter-cost helper's actual signature."""
|
||||
from app.services import meter_cost
|
||||
|
||||
client, _ = meters_client
|
||||
_login(client)
|
||||
calls = []
|
||||
monkeypatch.setattr(meter_cost, "recompute_range", lambda db, start, end, *, commit: calls.append((start, end, commit)) or 0)
|
||||
started = datetime.now(UTC) - timedelta(hours=2)
|
||||
declared = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"label": f"{commodity} meter", "started_at": started.isoformat(), "reason": "initial", "commodity": commodity,
|
||||
})
|
||||
assert declared.status_code == 201
|
||||
closed = client.post(f"/api/energy/meters/{declared.json()['id']}/close", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"ended_at": (started + timedelta(hours=1)).isoformat(),
|
||||
})
|
||||
assert closed.status_code == 200
|
||||
assert len(calls) == 2 and all(call[2] is False for call in calls)
|
||||
|
||||
|
||||
def test_close_meter_closes_open_bindings_and_enforces_auth_csrf(meters_client):
|
||||
client, engine = meters_client
|
||||
started = datetime.now(UTC) - timedelta(hours=2)
|
||||
_login(client)
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||
declared = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json=_declare_payload(
|
||||
label="closable", started_at=started.isoformat(), reason="initial",
|
||||
))
|
||||
assert declared.status_code == 201
|
||||
_add_bound_channel(engine, meter_id=declared.json()["id"], started_at=started)
|
||||
boundary = started + timedelta(hours=1)
|
||||
assert client.post(f"/api/energy/meters/{declared.json()['id']}/close", json={"ended_at": boundary.isoformat()}).status_code == 403
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||
closed = client.post(f"/api/energy/meters/{declared.json()['id']}/close", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"ended_at": boundary.isoformat(),
|
||||
})
|
||||
assert closed.status_code == 200
|
||||
with Session(engine) as session:
|
||||
binding = session.scalar(select(MeterSourceBinding))
|
||||
assert binding is not None and binding.ended_at.replace(tzinfo=UTC) == boundary
|
||||
|
||||
|
||||
@pytest.mark.parametrize("commodity,unit", [("electricity", "kWh"), ("heating", "GJ"), ("hot_water", "m³")])
|
||||
@pytest.mark.parametrize("operation", ["close", "declare"])
|
||||
def test_lifecycle_rejects_retained_closed_binding_beyond_proposed_end(
|
||||
meters_client, mock_publish_discovery, commodity, unit, operation,
|
||||
):
|
||||
"""Close and declare fail closed before recompute or HA for every commodity."""
|
||||
client, engine = meters_client
|
||||
_login(client)
|
||||
start = datetime.now(UTC) - timedelta(hours=3)
|
||||
boundary = start + timedelta(hours=1)
|
||||
retained_end = start + timedelta(hours=2)
|
||||
declared = client.post(
|
||||
"/api/energy/meters", headers={"X-CSRF-Token": _CSRF},
|
||||
json=_declare_payload(label="retained-history", started_at=start.isoformat(), reason="initial", commodity=commodity),
|
||||
)
|
||||
assert declared.status_code == 201
|
||||
meter_id = declared.json()["id"]
|
||||
_add_bound_channel(engine, meter_id=meter_id, started_at=start, ended_at=retained_end, unit=unit)
|
||||
mock_publish_discovery.reset_mock()
|
||||
|
||||
with patch("app.api.routes.api.meters._recompute_commodity", side_effect=AssertionError("must not recompute")):
|
||||
if operation == "close":
|
||||
response = client.post(
|
||||
f"/api/energy/meters/{meter_id}/close", headers={"X-CSRF-Token": _CSRF},
|
||||
json={"ended_at": boundary.isoformat()},
|
||||
)
|
||||
else:
|
||||
response = client.post(
|
||||
"/api/energy/meters", headers={"X-CSRF-Token": _CSRF},
|
||||
json=_declare_payload(
|
||||
label="replacement", started_at=boundary.isoformat(), reason="meter_swap", commodity=commodity,
|
||||
),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
assert mock_publish_discovery.call_count == 0
|
||||
with Session(engine) as observer:
|
||||
meter = observer.get(Meter, meter_id)
|
||||
binding = observer.scalar(select(MeterSourceBinding).where(MeterSourceBinding.meter_id == meter_id))
|
||||
assert meter is not None and meter.ended_at is None
|
||||
assert binding is not None and binding.ended_at.replace(tzinfo=UTC) == retained_end
|
||||
assert observer.scalars(select(Meter).where(Meter.commodity == commodity)).all() == [meter]
|
||||
|
||||
|
||||
def test_close_flushes_lifecycle_boundary_before_strict_recompute(meters_client):
|
||||
"""The strict recompute query observes the just-closed meter and binding."""
|
||||
client, engine = meters_client
|
||||
_login(client)
|
||||
started = datetime.now(UTC) - timedelta(hours=2)
|
||||
boundary = started + timedelta(hours=1)
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||
declared = client.post(
|
||||
"/api/energy/meters", headers={"X-CSRF-Token": _CSRF},
|
||||
json=_declare_payload(label="flush-visible", started_at=started.isoformat(), reason="initial"),
|
||||
)
|
||||
meter_id = declared.json()["id"]
|
||||
_add_bound_channel(engine, meter_id=meter_id, started_at=started)
|
||||
|
||||
def observe(session, *_args, **kwargs):
|
||||
assert kwargs == {"commit": False, "strict": True}
|
||||
observed_meter = session.get(Meter, meter_id)
|
||||
observed_binding = session.scalar(select(MeterSourceBinding))
|
||||
assert observed_meter is not None and observed_meter.ended_at is not None
|
||||
assert observed_binding is not None and observed_binding.ended_at is not None
|
||||
return 0
|
||||
|
||||
with patch("app.api.routes.api.meters.recompute_range", side_effect=observe):
|
||||
response = client.post(
|
||||
f"/api/energy/meters/{meter_id}/close", headers={"X-CSRF-Token": _CSRF},
|
||||
json={"ended_at": boundary.isoformat()},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_close_strict_compute_failure_rolls_back_persisted_lifecycle_state(meters_client, monkeypatch):
|
||||
"""A real per-period strict failure rolls back the close in a fresh Session."""
|
||||
from app.services import energy_cost
|
||||
|
||||
client, engine = meters_client
|
||||
_login(client)
|
||||
started = datetime.now(UTC) - timedelta(hours=2)
|
||||
boundary = started + timedelta(hours=1)
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||
declared = client.post(
|
||||
"/api/energy/meters", headers={"X-CSRF-Token": _CSRF},
|
||||
json=_declare_payload(label="strict-rollback", started_at=started.isoformat(), reason="initial"),
|
||||
)
|
||||
meter_id = declared.json()["id"]
|
||||
_add_bound_channel(engine, meter_id=meter_id, started_at=started)
|
||||
monkeypatch.setattr(
|
||||
energy_cost, "compute_period", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("strict boom"))
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="strict boom"):
|
||||
client.post(
|
||||
f"/api/energy/meters/{meter_id}/close", headers={"X-CSRF-Token": _CSRF},
|
||||
json={"ended_at": boundary.isoformat()},
|
||||
)
|
||||
with Session(engine) as observer:
|
||||
assert observer.get(Meter, meter_id).ended_at is None
|
||||
assert observer.scalar(select(MeterSourceBinding)).ended_at is None
|
||||
|
||||
|
||||
def test_close_flush_failure_rolls_back_persisted_lifecycle_state(meters_client, monkeypatch):
|
||||
"""The mandatory pre-recompute flush shares the route rollback boundary."""
|
||||
client, engine = meters_client
|
||||
_login(client)
|
||||
started = datetime.now(UTC) - timedelta(hours=2)
|
||||
with patch("app.api.routes.api.meters.recompute_range", return_value=0):
|
||||
declared = client.post(
|
||||
"/api/energy/meters", headers={"X-CSRF-Token": _CSRF},
|
||||
json=_declare_payload(label="flush-rollback", started_at=started.isoformat(), reason="initial"),
|
||||
)
|
||||
meter_id = declared.json()["id"]
|
||||
_add_bound_channel(engine, meter_id=meter_id, started_at=started)
|
||||
|
||||
monkeypatch.setattr(Session, "flush", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("flush boom")))
|
||||
with pytest.raises(RuntimeError, match="flush boom"):
|
||||
client.post(
|
||||
f"/api/energy/meters/{meter_id}/close", headers={"X-CSRF-Token": _CSRF},
|
||||
json={"ended_at": (started + timedelta(hours=1)).isoformat()},
|
||||
)
|
||||
monkeypatch.undo()
|
||||
with Session(engine) as observer:
|
||||
assert observer.get(Meter, meter_id).ended_at is None
|
||||
assert observer.scalar(select(MeterSourceBinding)).ended_at is None
|
||||
|
||||
@@ -2176,6 +2176,31 @@ class TestComputeClosedPeriods:
|
||||
|
||||
|
||||
class TestRecomputeRange:
|
||||
def test_strict_mode_propagates_period_failure_without_committing(
|
||||
self, energy_db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Lifecycle callers can own one rollback for writes and recompute."""
|
||||
from app.services import energy_cost
|
||||
|
||||
monkeypatch.setattr(
|
||||
energy_cost, "compute_period", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
recompute_range(energy_db, _T0, _T1, commit=False, strict=True)
|
||||
|
||||
def test_default_mode_keeps_best_effort_period_failure(
|
||||
self, energy_db: Session, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Existing workers retain their tolerant recompute behaviour by default."""
|
||||
from app.services import energy_cost
|
||||
|
||||
monkeypatch.setattr(
|
||||
energy_cost, "compute_period", lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
|
||||
assert recompute_range(energy_db, _T0, _T1, commit=False) == 0
|
||||
|
||||
def test_default_commit_is_visible_to_a_new_session(self, energy_db: Session) -> None:
|
||||
"""The public recompute API retains its standalone commit behaviour."""
|
||||
_setup_manual_scenario(energy_db)
|
||||
|
||||
+162
-14
@@ -10,6 +10,7 @@ import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -53,6 +54,13 @@ def _add_channel(engine, source_uuid: str, *, key: str = "electricity") -> str:
|
||||
return channel.uuid
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_lifecycle_recompute(monkeypatch):
|
||||
"""Keep lifecycle API contracts on synthetic DBs; cost engines have their own tests."""
|
||||
monkeypatch.setattr("app.api.routes.api.meters.recompute_range", lambda *args, **kwargs: 0)
|
||||
monkeypatch.setattr("app.api.routes.api.meter_sources.electricity_recompute_range", lambda *args, **kwargs: 0)
|
||||
|
||||
|
||||
def test_source_profiles_and_crud_mask_secrets(auth_database):
|
||||
client, engine = _client(auth_database)
|
||||
with client:
|
||||
@@ -268,7 +276,7 @@ def test_binding_routes_and_atomic_meter_declaration(auth_database):
|
||||
channel_uuid = channel.uuid
|
||||
|
||||
declaration = {
|
||||
"label": "Bound meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
|
||||
"label": "Bound meter", "started_at": "2025-01-01T00:00:00Z", "reason": "initial",
|
||||
"commodity": "electricity", "source_channel_uuid": channel_uuid,
|
||||
}
|
||||
created = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json=declaration)
|
||||
@@ -278,7 +286,7 @@ def test_binding_routes_and_atomic_meter_declaration(auth_database):
|
||||
assert client.get(f"/api/energy/meters/{meter_id}/bindings").json()["total"] == 1
|
||||
assert client.get(f"/api/energy/sources/{source_uuid}/channels").json()["items"][0]["binding_count"] == 1
|
||||
|
||||
invalid = dict(declaration, label="Must roll back", started_at="2030-02-01T00:00:00Z", source_channel_uuid="missing-channel")
|
||||
invalid = dict(declaration, label="Must roll back", started_at="2025-02-01T00:00:00Z", source_channel_uuid="missing-channel")
|
||||
assert client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json=invalid).status_code == 404
|
||||
assert client.get("/api/energy/meters").json()["total"] == 1
|
||||
with Session(engine) as session:
|
||||
@@ -403,12 +411,12 @@ def test_management_reads_require_auth_and_mutations_require_csrf(auth_database)
|
||||
source = _create_source(client)
|
||||
channel_uuid = _add_channel(engine, source["uuid"])
|
||||
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"label": "CSRF meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
|
||||
"label": "CSRF meter", "started_at": "2025-01-01T00:00:00Z", "reason": "initial",
|
||||
})
|
||||
assert meter.status_code == 201
|
||||
meter_id = meter.json()["id"]
|
||||
binding = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z",
|
||||
"source_channel_uuid": channel_uuid, "started_at": "2025-01-01T00:00:00Z",
|
||||
})
|
||||
assert binding.status_code == 201
|
||||
|
||||
@@ -467,11 +475,11 @@ def test_source_channel_binding_response_contract_and_discover_capabilities(auth
|
||||
assert channels.json()["source_status"] == "online"
|
||||
|
||||
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"label": "Contract meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
|
||||
"label": "Contract meter", "started_at": "2025-01-01T00:00:00Z", "reason": "initial",
|
||||
})
|
||||
assert meter.status_code == 201
|
||||
binding = client.post(f"/api/energy/meters/{meter.json()['id']}/bindings", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z",
|
||||
"source_channel_uuid": channel_uuid, "started_at": "2025-01-01T00:00:00Z",
|
||||
})
|
||||
assert binding.status_code == 201
|
||||
binding_item = client.get(f"/api/energy/meters/{meter.json()['id']}/bindings").json()["items"][0]
|
||||
@@ -492,38 +500,178 @@ def test_binding_patch_omitted_null_and_adjacent_half_open_boundaries(auth_datab
|
||||
source = _create_source(client)
|
||||
channel_uuid = _add_channel(engine, source["uuid"])
|
||||
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"label": "Timeline meter", "started_at": "2030-01-01T00:00:00Z", "reason": "initial",
|
||||
"label": "Timeline meter", "started_at": "2025-01-01T00:00:00Z", "reason": "initial",
|
||||
})
|
||||
assert meter.status_code == 201
|
||||
meter_id = meter.json()["id"]
|
||||
first = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"source_channel_uuid": channel_uuid, "started_at": "2030-01-01T00:00:00Z",
|
||||
"ended_at": "2030-02-01T00:00:00Z",
|
||||
"source_channel_uuid": channel_uuid, "started_at": "2025-01-01T00:00:00Z",
|
||||
"ended_at": "2025-02-01T00:00:00Z",
|
||||
})
|
||||
assert first.status_code == 201
|
||||
first_uuid = first.json()["uuid"]
|
||||
corrected = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"started_at": "2030-01-02T00:00:00Z",
|
||||
"started_at": "2025-01-02T00:00:00Z",
|
||||
})
|
||||
assert corrected.status_code == 200
|
||||
assert corrected.json()["ended_at"] == "2030-02-01T00:00:00"
|
||||
assert corrected.json()["ended_at"] == "2025-02-01T00:00:00"
|
||||
unchanged = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={})
|
||||
assert unchanged.status_code == 200
|
||||
assert unchanged.json()["ended_at"] == "2030-02-01T00:00:00"
|
||||
assert unchanged.json()["ended_at"] == "2025-02-01T00:00:00"
|
||||
reopened = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={"ended_at": None})
|
||||
assert reopened.status_code == 200
|
||||
assert reopened.json()["ended_at"] is None
|
||||
reclosed = client.patch(f"/api/energy/bindings/{first_uuid}", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"ended_at": "2030-02-01T00:00:00Z",
|
||||
"ended_at": "2025-02-01T00:00:00Z",
|
||||
})
|
||||
assert reclosed.status_code == 200
|
||||
adjacent = client.post(f"/api/energy/meters/{meter_id}/bindings", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"source_channel_uuid": channel_uuid, "started_at": "2030-02-01T00:00:00Z",
|
||||
"source_channel_uuid": channel_uuid, "started_at": "2025-02-01T00:00:00Z",
|
||||
})
|
||||
assert adjacent.status_code == 201
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_binding_create_and_meter_declare_reject_future_boundaries(auth_database):
|
||||
client, engine = _client(auth_database)
|
||||
with client:
|
||||
_login(client)
|
||||
source = _create_source(client)
|
||||
channel_uuid = _add_channel(engine, source["uuid"])
|
||||
future = (datetime.now(UTC) + timedelta(minutes=5)).isoformat()
|
||||
assert client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"label": "future", "started_at": future, "reason": "initial",
|
||||
}).status_code == 422
|
||||
past = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||
meter = client.post("/api/energy/meters", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"label": "past", "started_at": past, "reason": "initial",
|
||||
})
|
||||
assert meter.status_code == 201
|
||||
assert client.post(f"/api/energy/meters/{meter.json()['id']}/bindings", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"source_channel_uuid": channel_uuid, "started_at": future,
|
||||
}).status_code == 422
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_transfer_recovers_stranded_previous_meter_same_channel(auth_database):
|
||||
"""A retained open row on a closed predecessor is recoverable in one request."""
|
||||
client, engine = _client(auth_database)
|
||||
with client:
|
||||
_login(client)
|
||||
source = _create_source(client)
|
||||
channel_uuid = _add_channel(engine, source["uuid"])
|
||||
start = datetime.now(UTC) - timedelta(days=2)
|
||||
boundary = start + timedelta(days=1)
|
||||
with Session(engine) as session:
|
||||
channel = session.scalar(select(MeterSourceChannel).where(MeterSourceChannel.uuid == channel_uuid))
|
||||
assert channel is not None
|
||||
old = Meter(label="old", commodity="electricity", started_at=start, ended_at=boundary,
|
||||
reason="meter_swap", created_at=start)
|
||||
target = Meter(label="target", commodity="electricity", started_at=boundary,
|
||||
reason="meter_swap", created_at=boundary)
|
||||
session.add_all([old, target])
|
||||
session.flush()
|
||||
stranded = MeterSourceBinding(meter_id=old.id, channel_id=channel.id, started_at=start,
|
||||
created_at=start, updated_at=start)
|
||||
session.add(stranded)
|
||||
session.commit()
|
||||
target_id, stranded_uuid = target.id, stranded.uuid
|
||||
response = client.post(f"/api/energy/meters/{target_id}/bindings/transfer", headers={"X-CSRF-Token": _CSRF}, json={
|
||||
"from_binding_uuid": stranded_uuid, "to_source_channel_uuid": channel_uuid,
|
||||
"effective_at": (boundary + timedelta(hours=2)).isoformat(),
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["closed_binding"]["ended_at"] is not None
|
||||
assert response.json()["created_binding"]["started_at"].startswith((boundary + timedelta(hours=2)).isoformat()[:19])
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_transfer_recovers_unique_gapped_predecessor_after_commit(auth_database, monkeypatch):
|
||||
"""Recovery closes at the old epoch end and recomputes from that earliest boundary."""
|
||||
from app.api.routes.api import meter_sources
|
||||
|
||||
client, engine = _client(auth_database)
|
||||
calls: list[tuple[datetime, datetime, bool, bool]] = []
|
||||
published: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
meter_sources, "electricity_recompute_range",
|
||||
lambda _db, start, end, *, commit, strict: calls.append((start, end, commit, strict)) or 0,
|
||||
)
|
||||
monkeypatch.setattr(meter_sources, "_republish_after_commit", lambda _db: published.append(True))
|
||||
with client:
|
||||
_login(client)
|
||||
source = _create_source(client)
|
||||
channel_uuid = _add_channel(engine, source["uuid"])
|
||||
start = datetime.now(UTC) - timedelta(days=3)
|
||||
old_end = start + timedelta(days=1)
|
||||
target_start = old_end + timedelta(hours=3)
|
||||
effective_at = target_start + timedelta(hours=1)
|
||||
with Session(engine) as session:
|
||||
channel = session.scalar(select(MeterSourceChannel).where(MeterSourceChannel.uuid == channel_uuid))
|
||||
assert channel is not None
|
||||
old = Meter(label="old", commodity="electricity", started_at=start, ended_at=old_end,
|
||||
reason="meter_swap", created_at=start)
|
||||
target = Meter(label="target", commodity="electricity", started_at=target_start,
|
||||
reason="initial", created_at=target_start)
|
||||
session.add_all([old, target])
|
||||
session.flush()
|
||||
stranded = MeterSourceBinding(meter_id=old.id, channel_id=channel.id, started_at=start,
|
||||
created_at=start, updated_at=start)
|
||||
session.add(stranded)
|
||||
session.commit()
|
||||
target_id, stranded_uuid = target.id, stranded.uuid
|
||||
response = client.post(
|
||||
f"/api/energy/meters/{target_id}/bindings/transfer", headers={"X-CSRF-Token": _CSRF},
|
||||
json={"from_binding_uuid": stranded_uuid, "to_source_channel_uuid": channel_uuid,
|
||||
"effective_at": effective_at.isoformat()},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["closed_binding"]["ended_at"].startswith(old_end.isoformat()[:19])
|
||||
assert response.json()["created_binding"]["started_at"].startswith(effective_at.isoformat()[:19])
|
||||
assert calls and calls[0][0] == old_end and calls[0][2:] == (False, True)
|
||||
assert published == [True]
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_transfer_rejects_intervening_meter_without_changing_stranded_binding(auth_database):
|
||||
"""A non-predecessor recovery request is fail-closed and rolls back cleanly."""
|
||||
client, engine = _client(auth_database)
|
||||
with client:
|
||||
_login(client)
|
||||
source = _create_source(client)
|
||||
channel_uuid = _add_channel(engine, source["uuid"])
|
||||
start = datetime.now(UTC) - timedelta(days=4)
|
||||
old_end = start + timedelta(days=1)
|
||||
target_start = old_end + timedelta(days=2)
|
||||
with Session(engine) as session:
|
||||
channel = session.scalar(select(MeterSourceChannel).where(MeterSourceChannel.uuid == channel_uuid))
|
||||
assert channel is not None
|
||||
old = Meter(label="old", commodity="electricity", started_at=start, ended_at=old_end,
|
||||
reason="meter_swap", created_at=start)
|
||||
intervening = Meter(label="intervening", commodity="electricity", started_at=old_end,
|
||||
ended_at=target_start, reason="other", created_at=old_end)
|
||||
target = Meter(label="target", commodity="electricity", started_at=target_start,
|
||||
reason="initial", created_at=target_start)
|
||||
session.add_all([old, intervening, target])
|
||||
session.flush()
|
||||
stranded = MeterSourceBinding(meter_id=old.id, channel_id=channel.id, started_at=start,
|
||||
created_at=start, updated_at=start)
|
||||
session.add(stranded)
|
||||
session.commit()
|
||||
target_id, stranded_uuid, stranded_id = target.id, stranded.uuid, stranded.id
|
||||
response = client.post(
|
||||
f"/api/energy/meters/{target_id}/bindings/transfer", headers={"X-CSRF-Token": _CSRF},
|
||||
json={"from_binding_uuid": stranded_uuid, "to_source_channel_uuid": channel_uuid,
|
||||
"effective_at": (target_start + timedelta(hours=1)).isoformat()},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
with Session(engine) as observer:
|
||||
binding = observer.get(MeterSourceBinding, stranded_id)
|
||||
assert binding is not None and binding.ended_at is None
|
||||
assert observer.scalars(select(MeterSourceBinding)).all() == [binding]
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_warmtelink_discover_and_minute_history_are_bounded_and_private(auth_database, monkeypatch):
|
||||
"""Discover delegates to the manager; readings expose accepted minute samples only."""
|
||||
from app.api.routes.api import meter_sources
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.services.meter_sources import (
|
||||
create_binding_for_meter_swap,
|
||||
create_source,
|
||||
delete_source,
|
||||
transfer_binding,
|
||||
upsert_discovered_channel,
|
||||
)
|
||||
|
||||
@@ -184,7 +185,7 @@ def test_meter_swap_hands_off_only_the_previous_meter_binding(session):
|
||||
boundary = start + timedelta(days=1)
|
||||
old_meter = _meter(session, "heating", "old")
|
||||
old_meter.started_at = start
|
||||
old_meter.ended_at = boundary
|
||||
old_meter.ended_at = None
|
||||
new_meter = Meter(
|
||||
label="new",
|
||||
commodity="heating",
|
||||
@@ -198,6 +199,7 @@ def test_meter_swap_hands_off_only_the_previous_meter_binding(session):
|
||||
old_binding = create_binding(
|
||||
session, meter_id=old_meter.id, channel_id=channel.id, started_at=start
|
||||
)
|
||||
old_meter.ended_at = boundary
|
||||
session.flush()
|
||||
|
||||
new_binding = create_binding_for_meter_swap(
|
||||
@@ -241,7 +243,7 @@ def test_meter_swap_rejects_ambiguous_channel_without_closing_any_binding(sessio
|
||||
boundary = start + timedelta(days=1)
|
||||
old_meter = _meter(session, "heating", "old")
|
||||
old_meter.started_at = start
|
||||
old_meter.ended_at = boundary
|
||||
old_meter.ended_at = None
|
||||
new_meter = Meter(
|
||||
label="new",
|
||||
commodity="heating",
|
||||
@@ -254,6 +256,7 @@ def test_meter_swap_rejects_ambiguous_channel_without_closing_any_binding(sessio
|
||||
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)
|
||||
old_meter.ended_at = boundary
|
||||
session.add(
|
||||
MeterSourceBinding(
|
||||
meter_id=other_meter.id,
|
||||
@@ -304,6 +307,119 @@ def test_meter_swap_rejects_incompatible_channel(session):
|
||||
)
|
||||
|
||||
|
||||
def test_transfer_closes_and_opens_at_shared_boundary(session):
|
||||
start = datetime(2026, 8, 22, tzinfo=UTC)
|
||||
meter = _meter(session, "heating")
|
||||
meter.started_at = start
|
||||
_, old_channel = _source_and_channel(session, "warmtelink_serial", "GJ")
|
||||
_, new_channel = _source_and_channel(session, "warmtelink_serial", "GJ")
|
||||
old = create_binding(session, meter_id=meter.id, channel_id=old_channel.id, started_at=start)
|
||||
session.flush()
|
||||
|
||||
closed, created = transfer_binding(
|
||||
session, target_meter_id=meter.id, from_binding_id=old.id,
|
||||
to_channel_id=new_channel.id, effective_at=start + timedelta(hours=1),
|
||||
)
|
||||
|
||||
assert closed.ended_at == created.started_at == start + timedelta(hours=1)
|
||||
assert created.channel_id == new_channel.id
|
||||
|
||||
|
||||
def test_transfer_rejects_future_without_mutating_source_binding(session):
|
||||
start = datetime.now(UTC) - timedelta(hours=2)
|
||||
meter = _meter(session, "heating")
|
||||
meter.started_at = start
|
||||
_, old_channel = _source_and_channel(session, "warmtelink_serial", "GJ")
|
||||
_, new_channel = _source_and_channel(session, "warmtelink_serial", "GJ")
|
||||
old = create_binding(session, meter_id=meter.id, channel_id=old_channel.id, started_at=start)
|
||||
session.flush()
|
||||
|
||||
with pytest.raises(BindingValidationError, match="future"):
|
||||
transfer_binding(session, target_meter_id=meter.id, from_binding_id=old.id,
|
||||
to_channel_id=new_channel.id, effective_at=datetime.now(UTC) + timedelta(minutes=1))
|
||||
assert old.ended_at is None
|
||||
|
||||
|
||||
def test_cross_meter_transfer_recovers_stranded_same_channel(session):
|
||||
start = datetime(2026, 8, 20, tzinfo=UTC)
|
||||
boundary = start + timedelta(days=1)
|
||||
old = _meter(session, "heating", "old")
|
||||
old.started_at, old.ended_at = start, None
|
||||
new = Meter(label="new", commodity="heating", started_at=boundary, reason="meter_swap", created_at=boundary)
|
||||
session.add(new)
|
||||
session.flush()
|
||||
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
|
||||
stranded = create_binding(session, meter_id=old.id, channel_id=channel.id, started_at=start)
|
||||
old.ended_at = boundary
|
||||
session.flush()
|
||||
|
||||
closed, created = transfer_binding(
|
||||
session, target_meter_id=new.id, from_binding_id=stranded.id,
|
||||
to_channel_id=channel.id, effective_at=boundary + timedelta(hours=2),
|
||||
)
|
||||
|
||||
assert closed.ended_at == boundary
|
||||
assert created.started_at == boundary + timedelta(hours=2)
|
||||
assert created.channel_id == channel.id
|
||||
|
||||
|
||||
def test_cross_meter_transfer_recovers_unique_gapped_predecessor(session):
|
||||
"""A deliberate no-meter gap does not make the latest predecessor ambiguous."""
|
||||
start = datetime(2026, 8, 20, tzinfo=UTC)
|
||||
old_end = start + timedelta(days=1)
|
||||
target_start = old_end + timedelta(hours=3)
|
||||
old = _meter(session, "heating", "old")
|
||||
old.started_at, old.ended_at = start, old_end
|
||||
target = Meter(label="target", commodity="heating", started_at=target_start,
|
||||
reason="initial", created_at=target_start)
|
||||
session.add(target)
|
||||
session.flush()
|
||||
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
|
||||
stranded = MeterSourceBinding(
|
||||
meter_id=old.id, channel_id=channel.id, started_at=start,
|
||||
created_at=start, updated_at=start,
|
||||
)
|
||||
session.add(stranded)
|
||||
session.flush()
|
||||
|
||||
closed, created = transfer_binding(
|
||||
session, target_meter_id=target.id, from_binding_id=stranded.id,
|
||||
to_channel_id=channel.id, effective_at=target_start + timedelta(hours=1),
|
||||
)
|
||||
|
||||
assert closed.ended_at == old_end
|
||||
assert created.started_at == target_start + timedelta(hours=1)
|
||||
|
||||
|
||||
def test_cross_meter_transfer_rejects_closed_source_and_ambiguous_predecessor(session):
|
||||
start = datetime(2026, 8, 20, tzinfo=UTC)
|
||||
boundary = start + timedelta(days=1)
|
||||
old = _meter(session, "heating", "old")
|
||||
old.started_at, old.ended_at = start, None
|
||||
target = Meter(label="target", commodity="heating", started_at=boundary, reason="meter_swap", created_at=boundary)
|
||||
session.add(target)
|
||||
session.flush()
|
||||
_, channel = _source_and_channel(session, "warmtelink_serial", "GJ")
|
||||
source = create_binding(session, meter_id=old.id, channel_id=channel.id, started_at=start)
|
||||
old.ended_at = boundary
|
||||
session.flush()
|
||||
close_binding(session, source.id, ended_at=boundary - timedelta(hours=1))
|
||||
with pytest.raises(BindingValidationError, match="open binding"):
|
||||
transfer_binding(session, target_meter_id=target.id, from_binding_id=source.id,
|
||||
to_channel_id=channel.id, effective_at=boundary)
|
||||
assert source.ended_at == boundary - timedelta(hours=1)
|
||||
|
||||
source.ended_at = None # synthetic retained bad row, exactly the recovery input.
|
||||
duplicate = Meter(label="duplicate", commodity="heating", started_at=start + timedelta(hours=1),
|
||||
ended_at=boundary, reason="other", created_at=start)
|
||||
session.add(duplicate)
|
||||
session.flush()
|
||||
with pytest.raises(BindingValidationError, match="unique immediately preceding"):
|
||||
transfer_binding(session, target_meter_id=target.id, from_binding_id=source.id,
|
||||
to_channel_id=channel.id, effective_at=boundary)
|
||||
assert source.ended_at is None
|
||||
|
||||
|
||||
def test_source_delete_is_restricted_by_discovered_channel(session):
|
||||
source, _ = _source_and_channel(session, "dsmr_mqtt", "kWh")
|
||||
with pytest.raises(SourceDeleteRestrictedError):
|
||||
|
||||
@@ -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