"""Tests for M7-T05: Meter CRUD + swap declaration + retroactive recompute API. Coverage matrix --------------- GET /api/energy/meters - unauthenticated → 401 - authenticated, no meters → 200, items=[] - after declaring meters → items ordered by started_at asc POST /api/energy/meters - unauthenticated → 401 - missing CSRF → 403 - valid (first meter, no active) → 201, MeterResponse - valid swap (active meter exists) → 201, old meter closed, new meter active - retroactive started_at → 201, triggers recompute for affected window - started_at before active meter's started_at (overlap) → 422 PATCH /api/energy/meters/{id} - unauthenticated → 401 - missing CSRF → 403 - not found → 404 - rename label → 200, label updated - edit note → 200, note updated - correct started_at (retroactive) → 200, triggers recompute for affected window - started_at interval violation → 422 Retroactive recompute integration - PATCH started_at change triggers recompute over min(old, new)..now window - POST retroactive declaration triggers recompute from new started_at """ from __future__ import annotations from datetime import UTC, datetime from unittest.mock import patch import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from app.models.energy import Meter # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- _CSRF = "test-csrf-token" def _login(client: TestClient) -> None: resp = client.post( "/api/auth/login", json={"username": "admin", "password": "test-password"}, ) assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}" def _declare_payload(**overrides) -> dict: base = { "label": "Test Meter", "started_at": datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC).isoformat(), "reason": "initial", "commodity": "electricity", } base.update(overrides) return base # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def meters_client(auth_database): """TestClient + SQLAlchemy engine for Meter API tests.""" from app.main import create_app app_url = auth_database["app_url"] engine = create_engine(app_url, connect_args={"check_same_thread": False}) fastapi_app = create_app() with TestClient(fastapi_app) as test_client: yield test_client, engine engine.dispose() # --------------------------------------------------------------------------- # GET /api/energy/meters # --------------------------------------------------------------------------- def test_list_meters_unauthenticated_returns_401(meters_client): client, _ = meters_client resp = client.get("/api/energy/meters") assert resp.status_code == 401 def test_list_meters_empty(meters_client): client, _ = meters_client _login(client) resp = client.get("/api/energy/meters") assert resp.status_code == 200 body = resp.json() assert body["items"] == [] assert body["total"] == 0 def test_list_meters_ordered_by_started_at(meters_client): """After declaring two meters, list returns them in ascending started_at order.""" client, _ = meters_client _login(client) t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC) t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): # Declare first meter (initial) resp1 = client.post( "/api/energy/meters", json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"), headers={"X-CSRF-Token": _CSRF}, ) assert resp1.status_code == 201 # Declare swap meter resp2 = client.post( "/api/energy/meters", json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"), headers={"X-CSRF-Token": _CSRF}, ) assert resp2.status_code == 201 resp = client.get("/api/energy/meters") assert resp.status_code == 200 body = resp.json() assert body["total"] == 2 items = body["items"] # Ordered by started_at ascending: Meter A first, Meter B second assert items[0]["label"] == "Meter A" assert items[1]["label"] == "Meter B" # Meter B is active (ended_at is null) assert items[1]["ended_at"] is None # Meter A is closed assert items[0]["ended_at"] is not None # --------------------------------------------------------------------------- # POST /api/energy/meters # --------------------------------------------------------------------------- def test_declare_meter_unauthenticated_returns_401(meters_client): client, _ = meters_client resp = client.post( "/api/energy/meters", json=_declare_payload(), headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 401 def test_declare_meter_missing_csrf_returns_403(meters_client): client, _ = meters_client _login(client) resp = client.post("/api/energy/meters", json=_declare_payload()) assert resp.status_code == 403 def test_declare_meter_first_no_active(meters_client): """Declaring the first meter succeeds without closing any previous meter.""" client, engine = meters_client _login(client) t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): resp = client.post( "/api/energy/meters", json=_declare_payload(label="First Meter", started_at=t0.isoformat(), reason="initial"), headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 201 body = resp.json() assert body["label"] == "First Meter" assert body["commodity"] == "electricity" assert body["reason"] == "initial" assert body["ended_at"] is None # active # DB check: one meter, active with Session(engine) as s: meters = s.execute(select(Meter)).scalars().all() assert len(meters) == 1 assert meters[0].ended_at is None def test_declare_meter_swap_closes_previous(meters_client): """Declaring a swap closes the previous active meter at started_at.""" client, engine = meters_client _login(client) t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC) t1 = datetime(2025, 3, 15, 12, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): # First meter resp1 = client.post( "/api/energy/meters", json=_declare_payload(label="Old Meter", started_at=t0.isoformat(), reason="initial"), headers={"X-CSRF-Token": _CSRF}, ) assert resp1.status_code == 201 old_id = resp1.json()["id"] # Swap resp2 = client.post( "/api/energy/meters", json=_declare_payload(label="New Meter", started_at=t1.isoformat(), reason="meter_swap"), headers={"X-CSRF-Token": _CSRF}, ) assert resp2.status_code == 201 body2 = resp2.json() assert body2["label"] == "New Meter" assert body2["ended_at"] is None # new meter is active # DB check: old meter is closed at t1 with Session(engine) as s: old_meter = s.get(Meter, old_id) assert old_meter is not None assert old_meter.ended_at is not None # ended_at should equal t1 (modulo naive/aware round-trip) ended_naive = old_meter.ended_at if ended_naive.tzinfo is None: ended_naive = ended_naive.replace(tzinfo=UTC) assert ended_naive == t1 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 _login(client) t0 = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC) t_before = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): # Declare first meter resp = client.post( "/api/energy/meters", json=_declare_payload(started_at=t0.isoformat(), reason="initial"), headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 201 # Attempt to declare a meter before t0 → overlap error # (t_before is in the past so would trigger recompute, but service layer raises first) resp2 = client.post( "/api/energy/meters", json=_declare_payload( label="Backdated Meter", started_at=t_before.isoformat(), reason="meter_swap" ), headers={"X-CSRF-Token": _CSRF}, ) assert resp2.status_code == 422 assert "started_at" in resp2.json()["detail"].lower() def test_declare_meter_missing_fields_returns_422(meters_client): """Missing required fields (started_at, reason) → 422 from Pydantic validation.""" client, _ = meters_client _login(client) resp = client.post( "/api/energy/meters", json={"label": "No Reason Meter"}, # missing started_at and reason headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 422 def test_declare_meter_response_fields(meters_client): """POST response contains all expected MeterResponse fields.""" client, _ = meters_client _login(client) t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): resp = client.post( "/api/energy/meters", json=_declare_payload( label="Full Fields Meter", started_at=t0.isoformat(), reason="home_move", note="Testing note", ), headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 201 body = resp.json() for field in ("id", "label", "commodity", "started_at", "ended_at", "reason", "note", "created_at"): assert field in body, f"Missing field {field!r} in response" assert body["note"] == "Testing note" assert body["reason"] == "home_move" def test_declare_meter_retroactive_triggers_recompute(meters_client): """Declaring a meter with started_at in the past triggers recompute_range. Both POST calls are made with recompute_range mocked so the test does not spend time iterating over thousands of empty quarter-hour periods. """ client, _ = meters_client _login(client) t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) t_past = datetime(2025, 3, 1, 0, 0, 0, tzinfo=UTC) with patch( "app.api.routes.api.meters.recompute_range", return_value=5 ) as mock_recompute: # First meter (initial); also in the past, so recompute is called here too. client.post( "/api/energy/meters", json=_declare_payload(started_at=t0.isoformat(), reason="initial"), headers={"X-CSRF-Token": _CSRF}, ) # Reset call count before the swap we are actually testing. mock_recompute.reset_mock() # Retroactive swap: started_at in the past → should trigger recompute resp = client.post( "/api/energy/meters", json=_declare_payload( label="Retroactive Swap", started_at=t_past.isoformat(), reason="meter_swap" ), headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 201 # recompute_range should have been called with start == t_past assert mock_recompute.called call_args = mock_recompute.call_args recompute_start = call_args[0][1] # positional arg index 1 (session is 0) # Normalise for comparison if recompute_start.tzinfo is None: recompute_start = recompute_start.replace(tzinfo=UTC) assert recompute_start <= t_past # --------------------------------------------------------------------------- # PATCH /api/energy/meters/{id} # --------------------------------------------------------------------------- def test_patch_meter_unauthenticated_returns_401(meters_client): client, _ = meters_client resp = client.patch( "/api/energy/meters/1", json={"label": "Renamed"}, headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 401 def test_patch_meter_missing_csrf_returns_403(meters_client): client, _ = meters_client _login(client) t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): resp = client.post( "/api/energy/meters", json=_declare_payload(started_at=t0.isoformat()), headers={"X-CSRF-Token": _CSRF}, ) meter_id = resp.json()["id"] resp = client.patch(f"/api/energy/meters/{meter_id}", json={"label": "Renamed"}) assert resp.status_code == 403 def test_patch_meter_not_found_returns_404(meters_client): client, _ = meters_client _login(client) resp = client.patch( "/api/energy/meters/99999", json={"label": "Does Not Exist"}, headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 404 def test_patch_meter_rename_label(meters_client): """PATCH label updates the meter's human-readable label.""" client, engine = meters_client _login(client) t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): resp = client.post( "/api/energy/meters", json=_declare_payload(label="Original Label", started_at=t0.isoformat()), headers={"X-CSRF-Token": _CSRF}, ) meter_id = resp.json()["id"] resp = client.patch( f"/api/energy/meters/{meter_id}", json={"label": "Updated Label"}, headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 200 assert resp.json()["label"] == "Updated Label" # DB check with Session(engine) as s: m = s.get(Meter, meter_id) assert m.label == "Updated Label" def test_patch_meter_edit_note(meters_client): """PATCH note updates the meter's note field.""" client, _ = meters_client _login(client) t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): resp = client.post( "/api/energy/meters", json=_declare_payload(started_at=t0.isoformat(), note=None), headers={"X-CSRF-Token": _CSRF}, ) meter_id = resp.json()["id"] resp = client.patch( f"/api/energy/meters/{meter_id}", json={"note": "Added a note"}, headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 200 assert resp.json()["note"] == "Added a note" def test_patch_meter_started_at_retroactive_triggers_recompute(meters_client): """PATCH started_at triggers recompute over min(old, new)..now window.""" client, _ = meters_client _login(client) # Set up two meters: initial + swap. All POST calls are mocked to avoid # running recompute over thousands of empty historical periods. t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): client.post( "/api/energy/meters", json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"), headers={"X-CSRF-Token": _CSRF}, ) resp_b = client.post( "/api/energy/meters", json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"), headers={"X-CSRF-Token": _CSRF}, ) meter_b_id = resp_b.json()["id"] # Correct Meter B's started_at to a slightly different past timestamp t1_corrected = datetime(2024, 12, 15, 0, 0, 0, tzinfo=UTC) # earlier than t1 with patch( "app.api.routes.api.meters.recompute_range", return_value=10 ) as mock_recompute: resp = client.patch( f"/api/energy/meters/{meter_b_id}", json={"started_at": t1_corrected.isoformat()}, headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 200 # recompute should be triggered assert mock_recompute.called call_args = mock_recompute.call_args recompute_start = call_args[0][1] if recompute_start.tzinfo is None: recompute_start = recompute_start.replace(tzinfo=UTC) # Window start should be min(t1_corrected, t1) = t1_corrected assert recompute_start <= t1_corrected def test_patch_meter_started_at_interval_violation_returns_422(meters_client): """PATCH started_at that would create an invalid interval → 422.""" client, _ = meters_client _login(client) # Set up: initial meter A, then swap to B. POST calls mocked to avoid slow recompute. t0 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): resp_a = client.post( "/api/energy/meters", json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"), headers={"X-CSRF-Token": _CSRF}, ) meter_a_id = resp_a.json()["id"] client.post( "/api/energy/meters", json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"), headers={"X-CSRF-Token": _CSRF}, ) # Try to set Meter A's started_at to after its ended_at (t1) → interval error t_too_late = datetime(2025, 6, 1, 0, 0, 0, tzinfo=UTC) resp = client.patch( f"/api/energy/meters/{meter_a_id}", json={"started_at": t_too_late.isoformat()}, headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 422 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 _login(client) t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0) as mock_recompute: resp = client.post( "/api/energy/meters", json=_declare_payload(started_at=t0.isoformat()), headers={"X-CSRF-Token": _CSRF}, ) meter_id = resp.json()["id"] mock_recompute.reset_mock() resp = client.patch( f"/api/energy/meters/{meter_id}", json={"label": "Renamed Only"}, headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 200 # recompute should NOT be triggered (no started_at change) assert not mock_recompute.called # --------------------------------------------------------------------------- # Timeline continuity (recompute mocked to avoid slow computation over empty quarters) # --------------------------------------------------------------------------- def test_swap_timeline_continuity(meters_client): """After two swaps, meter timeline is contiguous and self-consistent.""" client, engine = meters_client _login(client) t0 = datetime(2023, 1, 1, 0, 0, 0, tzinfo=UTC) t1 = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) t2 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) # Declare 3 meters in sequence. POST calls mocked to avoid slow recompute over # years of empty quarter-hour periods. with patch("app.api.routes.api.meters.recompute_range", return_value=0): for label, ts, reason in [ ("M1", t0, "initial"), ("M2", t1, "meter_swap"), ("M3", t2, "meter_swap"), ]: resp = client.post( "/api/energy/meters", json=_declare_payload(label=label, started_at=ts.isoformat(), reason=reason), headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 201 # Verify timeline via list endpoint resp = client.get("/api/energy/meters") items = resp.json()["items"] assert len(items) == 3 # M1: [t0, t1); M2: [t1, t2); M3: [t2, None) m1 = next(i for i in items if i["label"] == "M1") m2 = next(i for i in items if i["label"] == "M2") m3 = next(i for i in items if i["label"] == "M3") assert m1["ended_at"] is not None assert m2["ended_at"] is not None assert m3["ended_at"] is None # active # ended_at of M1 == started_at of M2 (contiguous) m1_ended = datetime.fromisoformat(m1["ended_at"]).replace(tzinfo=None) m2_started = datetime.fromisoformat(m2["started_at"]).replace(tzinfo=None) assert m1_ended == m2_started m2_ended = datetime.fromisoformat(m2["ended_at"]).replace(tzinfo=None) m3_started = datetime.fromisoformat(m3["started_at"]).replace(tzinfo=None) assert m2_ended == m3_started # --------------------------------------------------------------------------- # Reason enum validation # --------------------------------------------------------------------------- def test_declare_meter_invalid_reason_returns_422(meters_client): """Unknown reason value → 422 from Pydantic enum validation.""" client, _ = meters_client _login(client) t0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) resp = client.post( "/api/energy/meters", json=_declare_payload(started_at=t0.isoformat(), reason="unknown_reason_xyz"), headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 422 # --------------------------------------------------------------------------- # Retroactive recompute & boundary update (recompute mocked) — window coverage check # --------------------------------------------------------------------------- def test_patch_started_at_earlier_updates_boundary(meters_client): """Moving started_at earlier should update the previous meter's ended_at.""" client, engine = meters_client _login(client) t0 = datetime(2024, 6, 1, 0, 0, 0, tzinfo=UTC) t1 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=UTC) t1_earlier = datetime(2024, 12, 1, 0, 0, 0, tzinfo=UTC) with patch("app.api.routes.api.meters.recompute_range", return_value=0): resp_a = client.post( "/api/energy/meters", json=_declare_payload(label="Meter A", started_at=t0.isoformat(), reason="initial"), headers={"X-CSRF-Token": _CSRF}, ) meter_a_id = resp_a.json()["id"] resp_b = client.post( "/api/energy/meters", json=_declare_payload(label="Meter B", started_at=t1.isoformat(), reason="meter_swap"), headers={"X-CSRF-Token": _CSRF}, ) meter_b_id = resp_b.json()["id"] # Correct Meter B's started_at to t1_earlier (moves boundary earlier) resp = client.patch( f"/api/energy/meters/{meter_b_id}", json={"started_at": t1_earlier.isoformat()}, headers={"X-CSRF-Token": _CSRF}, ) assert resp.status_code == 200 assert resp.json()["id"] == meter_b_id # DB check: Meter A's ended_at should now equal t1_earlier with Session(engine) as s: meter_a = s.get(Meter, meter_a_id) assert meter_a is not None ended = meter_a.ended_at if ended is not None and ended.tzinfo is None: ended = ended.replace(tzinfo=UTC) assert ended == t1_earlier