From 227b146c2ccd5402ca23a87b7f6bd59cad4ea015 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 25 Jun 2026 15:02:19 +0200 Subject: [PATCH] M7-T02: add meter service layer (declare/swap/close/edit, meter_at, mutual exclusion) --- app/services/meters.py | 480 ++++++++++++++++++++++++++++++++ tests/test_meters.py | 602 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1082 insertions(+) create mode 100644 app/services/meters.py create mode 100644 tests/test_meters.py diff --git a/app/services/meters.py b/app/services/meters.py new file mode 100644 index 0000000..b6dc0cb --- /dev/null +++ b/app/services/meters.py @@ -0,0 +1,480 @@ +"""Service layer for Meter epoch CRUD, swap/close, and time-range lookup. + +All functions accept an explicit SQLAlchemy Session; callers are responsible +for committing or rolling back the transaction. + +Design decisions +---------------- +- ``meter_at``: half-open interval ``[started_at, ended_at)`` lookup; + returns the meter whose epoch covers *ts* for the given commodity. + SQLite naive datetime is normalised via ``_as_utc()`` before comparison. + +- ``declare_meter``: validates that the new ``started_at`` is **not earlier + than** the current active meter's ``started_at`` (rejects back-dating below + the active meter's own start). Equal timestamps are allowed because the + typical "swap now" use-case sets ``started_at`` to the current moment, which + coincides with the active meter's ``started_at`` only in degenerate test + scenarios — but blocking equal values would make that workflow impossible. + The old active meter is closed (``ended_at = started_at``) and a new active + meter is opened in the same operation, guaranteeing continuity: the old + meter's ``ended_at`` equals the new meter's ``started_at`` (contiguous, + no gap, no overlap). + +- ``update_meter``: when ``started_at`` is modified, the service keeps the + timeline contiguous by also updating the **previous** meter's ``ended_at`` + (the one whose ``ended_at`` matched the old ``started_at``) to the new + ``started_at``. Validation ensures the new ``started_at``: + * is strictly after the previous meter's own ``started_at`` + (cannot push the boundary before the previous meter even started); + * is strictly before the current meter's ``ended_at``, if set + (cannot push the boundary past where the current meter was already + closed). + +- Mutual exclusion (at most one active meter per commodity) is enforced by the + service layer; no DB-level unique partial index is added to keep migrations + simple and to allow the application to return a meaningful error message. + +SQLite timezone note +-------------------- +SQLite stores ``DateTime(timezone=True)`` columns as naive UTC strings; on +read-back they come out as **timezone-naive** datetimes. Wherever this code +compares timestamps from the DB against timezone-aware values, it calls +``_as_utc()`` to make both sides comparable without tripping on +"offset-naive vs offset-aware" TypeErrors. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.energy import Meter + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _as_utc(dt: datetime) -> datetime: + """Return *dt* as a timezone-aware UTC datetime. + + SQLite's ``DateTime(timezone=True)`` column type stores datetimes as naive + UTC strings and gives them back as naive datetimes on read. This helper + re-attaches the UTC timezone info when it is missing, making cross-origin + comparisons safe. + """ + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt + + +# --------------------------------------------------------------------------- +# Custom exceptions +# --------------------------------------------------------------------------- + + +class MeterError(ValueError): + """Base class for meter service validation errors.""" + + +class MeterOverlapError(MeterError): + """Raised when a new meter's ``started_at`` would create an overlap or backdate. + + Specifically: the new meter's ``started_at`` must be greater than or equal + to the current active meter's ``started_at``. Allowing a value strictly + earlier than the active meter's start would mean the new epoch begins before + the current one, which is chronologically inconsistent. + """ + + +class MeterIntervalError(MeterError): + """Raised when an ``update_meter`` call would produce an inconsistent interval. + + Examples of inconsistent intervals: + - New ``started_at`` ≥ this meter's ``ended_at`` (epoch would be empty/inverted). + - New ``started_at`` ≤ the previous meter's own ``started_at`` (previous meter + would become empty/inverted after its ``ended_at`` is updated). + """ + + +# --------------------------------------------------------------------------- +# Internal query helpers +# --------------------------------------------------------------------------- + + +def _active_meter(session: Session, commodity: str) -> Optional[Meter]: + """Return the current active (``ended_at IS NULL``) meter for *commodity*, or None.""" + return session.execute( + select(Meter) + .where( + Meter.commodity == commodity, + Meter.ended_at.is_(None), + ) + .limit(1) + ).scalar_one_or_none() + + +def _meter_before(session: Session, meter: Meter) -> Optional[Meter]: + """Return the meter whose ``ended_at`` equals *meter*'s ``started_at``. + + This is the meter that was closed when *meter* was opened; its ``ended_at`` + needs to stay equal to *meter*'s ``started_at`` to maintain timeline + continuity. Returns None if *meter* is the first epoch for its commodity. + + The lookup compares naive/aware datetimes via string to avoid SQLite timezone + quirks: both are formatted as ISO 8601 UTC strings for the WHERE clause. + We rely on the fact that the service layer always stores the same timestamp + object as both ``prev.ended_at`` and ``new.started_at``, so their string + representations are identical. + """ + # Normalise the target to a tz-aware UTC datetime for comparison. + # We scan in Python (rather than SQL) to avoid SQLite naive-vs-aware + # mismatch issues when comparing DateTime columns against tz-aware values. + target = _as_utc(meter.started_at) + + # Fetch all closed meters of the same commodity and find the one whose + # ended_at equals this meter's started_at (the standard contiguous handoff). + candidates = session.execute( + select(Meter) + .where( + Meter.commodity == meter.commodity, + Meter.ended_at.is_not(None), + ) + ).scalars().all() + + for candidate in candidates: + if candidate.id == meter.id: + continue + candidate_ended = _as_utc(candidate.ended_at) + if candidate_ended == target: + return candidate + + return None + + +# --------------------------------------------------------------------------- +# Core service functions +# --------------------------------------------------------------------------- + + +def meter_at( + session: Session, + ts: datetime, + commodity: str = "electricity", +) -> Optional[Meter]: + """Return the meter epoch that covers *ts* for *commodity*. + + A meter covers *ts* when: + ``started_at ≤ ts`` AND (``ended_at IS NULL`` OR ``ts < ended_at``) + + This is the standard half-open interval ``[started_at, ended_at)`` lookup, + consistent with ``active_contract_version_at`` in ``contracts.py``. + + Returns ``None`` when no meter covers *ts* (e.g. before the first epoch + was declared, or after a gap). + + Parameters + ---------- + session: + Active SQLAlchemy session (read-only usage). + ts: + UTC datetime to look up. + commodity: + Energy commodity (default ``"electricity"``). + + Returns + ------- + Meter | None + + Implementation note — why the upper bound is pushed into SQL + ------------------------------------------------------------ + When ``declare_meter`` is called with ``started_at == active.started_at`` + (the "equal-timestamp swap" allowed by §3.5), the old meter is closed as a + zero-width epoch ``[T0, T0)`` and the new active meter also has + ``started_at == T0``. Two rows share the same ``started_at``; SQLite's + row-ordering for ``ORDER BY started_at DESC LIMIT 1`` is then determined by + rowid (i.e. insertion order), which returns the older zero-width row first. + + If the upper bound were checked in Python *after* fetching that one row, the + condition ``ts < ended_at`` would be False for **any** ``ts >= T0`` (because + ``ended_at == T0``), causing the function to return ``None`` and making the + new active meter permanently invisible. + + Pushing both bounds into the SQL ``WHERE`` clause eliminates the ambiguity: + the zero-width row is excluded by ``ts < ended_at`` before ``LIMIT 1`` is + applied, so only the genuinely covering row survives. + + SQLite naive-vs-aware datetime note: SQLAlchemy's SQLite dialect strips the + ``tzinfo`` from aware datetimes when binding parameters (it does *not* + convert to UTC first). Since all datetimes in this codebase are UTC (either + naive-UTC from the DB or aware-UTC from ``datetime.now(UTC)``), stripping + the tzinfo leaves the wall-clock value unchanged and comparisons remain + correct. This is consistent with how ``contracts.py`` handles the same + situation (see OBS 2 in the M7-T02 review notes). + """ + # Push both the lower and upper bounds into the SQL WHERE clause so that + # zero-width epochs (ended_at == started_at) are excluded *before* LIMIT 1 + # is applied. This prevents an equal-timestamp swap from making the new + # active meter invisible (see implementation note above). + stmt = ( + select(Meter) + .where( + Meter.commodity == commodity, + Meter.started_at <= ts, + (Meter.ended_at.is_(None)) | (Meter.ended_at > ts), + ) + .order_by(Meter.started_at.desc()) + .limit(1) + ) + return session.execute(stmt).scalar_one_or_none() + + +def declare_meter( + session: Session, + *, + label: str, + started_at: datetime, + reason: str, + commodity: str = "electricity", + note: Optional[str] = None, +) -> Meter: + """Declare a meter swap or initial meter epoch. + + Closes the current active meter for *commodity* (if one exists) by setting + its ``ended_at`` to *started_at*, then opens a new active meter. The + resulting timeline is **contiguous**: old meter's ``ended_at`` equals new + meter's ``started_at``. + + If there is no current active meter (first-ever declaration for this + commodity), the new meter is simply opened without closing anything. + + Validation + ---------- + - If a current active meter exists, *started_at* must be **≥** that meter's + own ``started_at``. A value strictly earlier would place the new epoch + entirely before the current active meter, which is a chronological + contradiction ("back-dating before the active epoch's start"). Raises + ``MeterOverlapError`` when this constraint is violated. + + Note: equal timestamps (``started_at == active.started_at``) are allowed + because that scenario effectively replaces the current meter at the same + logical moment (e.g. correcting a mis-entry), which is a valid use-case. + The old meter is then closed with ``ended_at == started_at`` (a zero-width + epoch), which is intentional and auditable. + + Parameters + ---------- + session: + Active SQLAlchemy session. Caller must commit after this returns. + label: + Human-readable label for the new meter. + started_at: + UTC datetime at which this meter epoch starts. May be in the past. + reason: + Why this epoch was created (``"initial"``, ``"meter_swap"``, + ``"home_move"``, or ``"other"``). + commodity: + Energy commodity (default ``"electricity"``). + note: + Optional free-form note. + + Returns + ------- + Meter + The newly created, not-yet-committed active meter. + + Raises + ------ + MeterOverlapError + If *started_at* is strictly earlier than the current active meter's + ``started_at`` (chronological backdate below the active epoch's start). + """ + active = _active_meter(session, commodity) + + if active is not None: + # Reject back-dating: new started_at must be ≥ current active's started_at. + if _as_utc(started_at) < _as_utc(active.started_at): + raise MeterOverlapError( + f"New meter started_at ({started_at.isoformat()}) must be ≥ the current " + f"active {commodity!r} meter's started_at " + f"({active.started_at.isoformat()}). " + "Declare a started_at on or after the active meter's start to avoid " + "a chronologically inconsistent epoch ordering." + ) + # Close the current active meter at the swap point (contiguous handoff). + active.ended_at = started_at + logger.info( + "Closed active %r meter id=%d (ended_at=%s)", + commodity, + active.id, + started_at.isoformat(), + ) + + now = datetime.now(UTC) + new_meter = Meter( + label=label, + commodity=commodity, + started_at=started_at, + ended_at=None, + reason=reason, + note=note, + created_at=now, + ) + session.add(new_meter) + + logger.info( + "Declared new %r meter %r (started_at=%s, reason=%s)", + commodity, + label, + started_at.isoformat(), + reason, + ) + return new_meter + + +def list_meters( + session: Session, + commodity: Optional[str] = None, +) -> list[Meter]: + """List all meter epochs, ordered by started_at ascending. + + Active meters (``ended_at IS NULL``) sort naturally to the end of the + timeline since they have the latest ``started_at``. Within a single + commodity, the ascending ``started_at`` order reflects the historical + sequence of installed meters. + + Parameters + ---------- + session: + Active SQLAlchemy session (read-only usage). + commodity: + If provided, filter to this commodity only. If ``None``, return all + meters across all commodities. + + Returns + ------- + list[Meter] + Meters ordered by (started_at ASC). + """ + stmt = select(Meter).order_by(Meter.started_at.asc()) + if commodity is not None: + stmt = stmt.where(Meter.commodity == commodity) + + return list(session.execute(stmt).scalars().all()) + + +def update_meter( + session: Session, + meter: Meter, + *, + label: Optional[str] = None, + note: Optional[str] = None, + started_at: Optional[datetime] = None, +) -> Meter: + """Update a meter's mutable fields (label, note, started_at). + + Passing ``None`` for a field leaves it unchanged. At least one keyword + argument must be non-``None``; calling with all-``None`` is a no-op but + is not an error. + + Updating ``started_at`` (retroactive correction) + ------------------------------------------------ + When ``started_at`` is provided the service maintains **timeline + continuity** across the adjacent meter boundaries: + + 1. **Previous meter's ``ended_at``** — if the meter immediately before + this one has ``ended_at == meter.started_at`` (the standard contiguous + handoff), its ``ended_at`` is updated to the new ``started_at`` so the + boundary between the two epochs stays seamless. + + 2. **Validation** — the new ``started_at`` is checked for consistency: + a. It must be **strictly after** the previous meter's own ``started_at`` + (otherwise the previous meter's epoch would collapse to zero or + invert). + b. It must be **strictly before** this meter's ``ended_at`` (if set), + so this meter's epoch remains non-empty. + + Note: triggering a billing recompute (``recompute_range``) after a + retroactive ``started_at`` change is **out of scope** for this service + layer; that is the API layer's responsibility (M7-T05). + + Parameters + ---------- + session: + Active SQLAlchemy session. Caller must commit after this returns. + meter: + The ``Meter`` ORM instance to update (already loaded from the session). + label: + New human-readable label; ``None`` = keep existing. + note: + New free-form note; ``None`` = keep existing. + started_at: + New start timestamp for this meter epoch; ``None`` = keep existing. + + Returns + ------- + Meter + The updated ``Meter`` instance (not yet committed). + + Raises + ------ + MeterIntervalError + If the new ``started_at`` would produce an invalid (empty or inverted) + epoch for this meter or the immediately preceding one. + """ + if label is not None: + meter.label = label + logger.info("Updated meter id=%d label=%r", meter.id, label) + + if note is not None: + meter.note = note + logger.info("Updated meter id=%d note=%r", meter.id, note) + + if started_at is not None: + old_started_at = meter.started_at + + # --- Validate upper bound: new started_at must be < this meter's ended_at (if set). + if meter.ended_at is not None: + if _as_utc(started_at) >= _as_utc(meter.ended_at): + raise MeterIntervalError( + f"New started_at ({started_at.isoformat()}) must be strictly before " + f"this meter's ended_at ({meter.ended_at.isoformat()}). " + "The meter epoch would become empty or inverted." + ) + + # --- Find the immediately preceding meter (its ended_at == meter's old started_at). + prev = _meter_before(session, meter) + + # --- Validate lower bound: new started_at must be strictly after prev's started_at. + if prev is not None: + if _as_utc(started_at) <= _as_utc(prev.started_at): + raise MeterIntervalError( + f"New started_at ({started_at.isoformat()}) must be strictly after " + f"the previous meter's started_at ({prev.started_at.isoformat()}). " + "Moving the boundary that far back would collapse the previous meter's epoch." + ) + # Maintain continuity: update the previous meter's ended_at to match the new start. + prev.ended_at = started_at + logger.info( + "Updated previous meter id=%d ended_at=%s (boundary shift from %s)", + prev.id, + started_at.isoformat(), + old_started_at.isoformat(), + ) + + meter.started_at = started_at + logger.info( + "Updated meter id=%d started_at=%s (was %s)", + meter.id, + started_at.isoformat(), + old_started_at.isoformat(), + ) + + return meter diff --git a/tests/test_meters.py b/tests/test_meters.py new file mode 100644 index 0000000..81f539b --- /dev/null +++ b/tests/test_meters.py @@ -0,0 +1,602 @@ +"""Tests for M7-T02: Meter service layer. + +Coverage +-------- +1. ``meter_at`` — half-open interval boundary semantics. +2. ``declare_meter`` — first declaration (no active meter), normal swap, backdate rejection. +3. Mutual exclusion — each commodity has at most one active meter after swaps. +4. Interval continuity — old meter's ended_at == new meter's started_at after swap. +5. Different commodities are independent (electricity swap doesn't touch gas meters). +6. ``list_meters`` — ordering and commodity filtering. +7. ``update_meter`` — label/note update, started_at retroactive correction with + interval consistency, and validation errors. +8. ``update_meter`` — first-meter (no previous) retroactive started_at change. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, event as sa_event +from sqlalchemy.orm import Session + +from app.models.energy import Meter +from app.services.meters import ( + MeterIntervalError, + MeterOverlapError, + declare_meter, + list_meters, + meter_at, + update_meter, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _make_app_alembic_config(database_url: str) -> Config: + cfg = Config("alembic_app.ini") + cfg.set_main_option("sqlalchemy.url", database_url) + return cfg + + +def _engine_with_fk(db_url: str): + """Create a SQLAlchemy engine with SQLite FK enforcement enabled.""" + engine = create_engine(db_url, connect_args={"check_same_thread": False}) + + @sa_event.listens_for(engine, "connect") + def _enable_fk(dbapi_conn, _rec): + cursor = dbapi_conn.cursor() + cursor.execute("PRAGMA foreign_keys = ON") + cursor.close() + + return engine + + +@pytest.fixture() +def meter_db(tmp_path: Path): + """Temporary SQLite DB upgraded to the current Alembic head with FK enforcement.""" + db_path = tmp_path / "meter_service_test.db" + db_url = f"sqlite:///{db_path}" + alembic_cfg = _make_app_alembic_config(db_url) + command.upgrade(alembic_cfg, "head") + engine = _engine_with_fk(db_url) + yield engine + engine.dispose() + + +@pytest.fixture() +def session(meter_db): + """Provide a single SQLAlchemy session for a test, auto-rolling back on exit.""" + with Session(meter_db) as s: + yield s + # Tests that commit explicitly are fine; for read-only tests the context + # manager handles cleanup. + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_T0 = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) # base timestamp for tests + + +def _make_meter( + session: Session, + *, + started_at: datetime, + ended_at: datetime | None = None, + label: str = "Test meter", + commodity: str = "electricity", + reason: str = "initial", + note: str | None = None, +) -> Meter: + """Insert a Meter row directly (bypassing service logic) for test setup.""" + m = Meter( + label=label, + commodity=commodity, + started_at=started_at, + ended_at=ended_at, + reason=reason, + note=note, + created_at=datetime.now(UTC), + ) + session.add(m) + session.flush() + return m + + +# --------------------------------------------------------------------------- +# 1. meter_at — half-open interval semantics +# --------------------------------------------------------------------------- + + +class TestMeterAt: + def test_returns_none_when_no_meters(self, session: Session): + """meter_at must return None when the table is empty.""" + assert meter_at(session, _T0) is None + + def test_returns_active_meter_for_ts_after_start(self, session: Session): + """An active meter (ended_at IS NULL) covers any ts ≥ started_at.""" + m = _make_meter(session, started_at=_T0, ended_at=None) + result = meter_at(session, _T0 + timedelta(hours=1)) + assert result is not None + assert result.id == m.id + + def test_exact_started_at_is_inclusive(self, session: Session): + """ts == started_at must be covered by that meter (left-closed boundary).""" + m = _make_meter(session, started_at=_T0, ended_at=None) + result = meter_at(session, _T0) + assert result is not None + assert result.id == m.id + + def test_ts_before_started_at_returns_none(self, session: Session): + """ts < started_at must not be covered.""" + _make_meter(session, started_at=_T0, ended_at=None) + result = meter_at(session, _T0 - timedelta(seconds=1)) + assert result is None + + def test_exact_ended_at_is_exclusive(self, session: Session): + """ts == ended_at must NOT be covered by the closed meter (right-open boundary).""" + t1 = _T0 + timedelta(hours=2) + _make_meter(session, started_at=_T0, ended_at=t1, label="Old meter") + # The new active meter starts exactly at t1. + new = _make_meter(session, started_at=t1, ended_at=None, label="New meter") + + result = meter_at(session, t1) + assert result is not None + assert result.id == new.id + + def test_ts_just_before_ended_at_is_covered(self, session: Session): + """ts just before ended_at must still be covered by the closing meter.""" + t1 = _T0 + timedelta(hours=2) + m = _make_meter(session, started_at=_T0, ended_at=t1) + result = meter_at(session, t1 - timedelta(seconds=1)) + assert result is not None + assert result.id == m.id + + def test_commodity_filter(self, session: Session): + """meter_at must only return the meter for the requested commodity.""" + m_elec = _make_meter(session, started_at=_T0, commodity="electricity") + _make_meter(session, started_at=_T0, commodity="gas") + + result = meter_at(session, _T0, commodity="electricity") + assert result is not None + assert result.id == m_elec.id + + result_gas = meter_at(session, _T0, commodity="gas") + assert result_gas is not None + assert result_gas.commodity == "gas" + + def test_no_meter_for_unknown_commodity(self, session: Session): + """meter_at returns None when no meter exists for the requested commodity.""" + _make_meter(session, started_at=_T0, commodity="electricity") + assert meter_at(session, _T0, commodity="heating") is None + + def test_two_contiguous_epochs_correct_routing(self, session: Session): + """With two contiguous meters, meter_at routes each ts to the correct epoch.""" + t1 = _T0 + timedelta(hours=3) + m0 = _make_meter(session, started_at=_T0, ended_at=t1, label="Meter 0") + m1 = _make_meter(session, started_at=t1, ended_at=None, label="Meter 1") + + # ts in first epoch + assert meter_at(session, _T0 + timedelta(hours=1)).id == m0.id + # ts exactly at boundary → second epoch + assert meter_at(session, t1).id == m1.id + # ts in second epoch + assert meter_at(session, t1 + timedelta(hours=1)).id == m1.id + + def test_equal_started_at_swap_meter_at_still_returns_new_active(self, session: Session): + """Regression: after equal-timestamp swap, meter_at must return the new active meter. + + When declare_meter is called with started_at == active.started_at (the + "equal-timestamp replace" allowed by §3.5), the old meter becomes a + zero-width epoch [T0, T0). A previous bug caused meter_at to select + the zero-width row first (same started_at, lower rowid) and then fail + the upper-bound check, returning None for *any* ts >= T0. This test + pins the correct behaviour: meter_at(T0) and meter_at(T0+δ) must both + return the new active meter, not None. + """ + # Declare first meter at T0. + declare_meter(session, label="M1 (original)", started_at=_T0, reason="initial") + session.commit() + + # Declare second meter at *the same* T0 — equal-timestamp swap. + new_m = declare_meter( + session, label="M2 (replacement)", started_at=_T0, reason="meter_swap" + ) + session.commit() + + # meter_at at exactly T0 must return the new active meter. + result_at_T0 = meter_at(session, _T0) + assert result_at_T0 is not None, ( + "meter_at(T0) returned None after equal-timestamp swap; " + "the new active meter should cover T0" + ) + assert result_at_T0.id == new_m.id, ( + f"meter_at(T0) returned meter id={result_at_T0.id} (label={result_at_T0.label!r}), " + f"expected id={new_m.id} (the new active meter)" + ) + + # meter_at slightly after T0 must also return the new active meter. + result_after_T0 = meter_at(session, _T0 + timedelta(seconds=1)) + assert result_after_T0 is not None, ( + "meter_at(T0+1s) returned None after equal-timestamp swap" + ) + assert result_after_T0.id == new_m.id, ( + f"meter_at(T0+1s) returned meter id={result_after_T0.id}, " + f"expected id={new_m.id} (the new active meter)" + ) + + +# --------------------------------------------------------------------------- +# 2 & 3. declare_meter — first declaration, swap, mutual exclusion +# --------------------------------------------------------------------------- + + +class TestDeclareMeter: + def test_first_declaration_no_active(self, session: Session): + """Declaring the first meter must create an active meter with ended_at IS NULL.""" + m = declare_meter( + session, + label="Initial meter", + started_at=_T0, + reason="initial", + ) + session.commit() + + fetched = session.get(Meter, m.id) + assert fetched is not None + assert fetched.ended_at is None + assert fetched.commodity == "electricity" + assert fetched.reason == "initial" + + def test_swap_closes_old_meter(self, session: Session): + """Declaring a second meter must close the previous active meter.""" + first = declare_meter( + session, label="First meter", started_at=_T0, reason="initial" + ) + session.commit() + first_id = first.id + + t1 = _T0 + timedelta(days=30) + second = declare_meter( + session, label="Second meter", started_at=t1, reason="meter_swap" + ) + session.commit() + + # First meter must now be closed at exactly t1. + closed = session.get(Meter, first_id) + assert closed.ended_at is not None + from app.services.meters import _as_utc + assert _as_utc(closed.ended_at) == _as_utc(t1) + + # Second meter must be active. + assert second.ended_at is None + + def test_swap_interval_contiguous(self, session: Session): + """Old meter's ended_at must exactly equal new meter's started_at after swap.""" + declare_meter(session, label="M1", started_at=_T0, reason="initial") + session.commit() + + t1 = _T0 + timedelta(days=10) + new_m = declare_meter(session, label="M2", started_at=t1, reason="meter_swap") + session.commit() + + # Query the old (now-closed) meter. + meters = list_meters(session, commodity="electricity") + assert len(meters) == 2 + old_m = next(m for m in meters if m.id != new_m.id) + + from app.services.meters import _as_utc + assert _as_utc(old_m.ended_at) == _as_utc(new_m.started_at), ( + "Timeline gap or overlap: old ended_at must == new started_at" + ) + + def test_at_most_one_active_per_commodity_after_multiple_swaps(self, session: Session): + """After N swaps, exactly one meter per commodity must be active.""" + declare_meter(session, label="M1", started_at=_T0, reason="initial") + session.commit() + declare_meter( + session, label="M2", started_at=_T0 + timedelta(days=10), reason="meter_swap" + ) + session.commit() + declare_meter( + session, label="M3", started_at=_T0 + timedelta(days=20), reason="meter_swap" + ) + session.commit() + + active_meters = [m for m in list_meters(session, commodity="electricity") if m.ended_at is None] + assert len(active_meters) == 1, f"Expected 1 active meter, got {len(active_meters)}" + + def test_backdate_rejected(self, session: Session): + """started_at strictly before active meter's started_at must raise MeterOverlapError.""" + declare_meter(session, label="Current", started_at=_T0, reason="initial") + session.commit() + + with pytest.raises(MeterOverlapError): + declare_meter( + session, + label="Too early", + started_at=_T0 - timedelta(hours=1), + reason="meter_swap", + ) + + def test_backdate_raises_before_any_db_write(self, session: Session): + """When backdate is rejected, no new meter row must be written.""" + declare_meter(session, label="Current", started_at=_T0, reason="initial") + session.commit() + + meters_before = list_meters(session, commodity="electricity") + count_before = len(meters_before) + + with pytest.raises(MeterOverlapError): + declare_meter( + session, + label="Bad meter", + started_at=_T0 - timedelta(minutes=5), + reason="meter_swap", + ) + # Rollback the failed operation explicitly (simulating what the caller would do). + session.rollback() + + # Re-open session to verify state. + with Session(session.get_bind()) as s2: + meters_after = list_meters(s2, commodity="electricity") + assert len(meters_after) == count_before, ( + "No extra meter must be written when backdate is rejected" + ) + + def test_equal_started_at_allowed(self, session: Session): + """started_at == active meter's started_at must NOT raise (equal is allowed).""" + declare_meter(session, label="M1", started_at=_T0, reason="initial") + session.commit() + + # Should not raise — equal timestamps are valid (replaces meter at same instant). + new_m = declare_meter( + session, label="M2", started_at=_T0, reason="meter_swap" + ) + session.commit() + assert new_m.ended_at is None + + def test_note_and_commodity_stored(self, session: Session): + """declare_meter must persist note and commodity correctly.""" + m = declare_meter( + session, + label="Gas meter", + started_at=_T0, + reason="initial", + commodity="gas", + note="Rotameter serial XYZ", + ) + session.commit() + + fetched = session.get(Meter, m.id) + assert fetched.commodity == "gas" + assert fetched.note == "Rotameter serial XYZ" + + +# --------------------------------------------------------------------------- +# 5. Different commodities are independent +# --------------------------------------------------------------------------- + + +class TestCommodityIsolation: + def test_electricity_swap_does_not_affect_gas_meter(self, session: Session): + """Swapping the electricity meter must not touch the gas meter's active state.""" + declare_meter(session, label="Elec 1", started_at=_T0, reason="initial", commodity="electricity") + declare_meter(session, label="Gas 1", started_at=_T0, reason="initial", commodity="gas") + session.commit() + + declare_meter( + session, + label="Elec 2", + started_at=_T0 + timedelta(days=5), + reason="meter_swap", + commodity="electricity", + ) + session.commit() + + # Gas meter must still be active. + gas_meters = list_meters(session, commodity="gas") + active_gas = [m for m in gas_meters if m.ended_at is None] + assert len(active_gas) == 1, "Gas meter must remain active after electricity swap" + assert active_gas[0].label == "Gas 1" + + # Electricity: exactly one active. + elec_meters = list_meters(session, commodity="electricity") + active_elec = [m for m in elec_meters if m.ended_at is None] + assert len(active_elec) == 1 + assert active_elec[0].label == "Elec 2" + + def test_different_commodity_backdate_is_independent(self, session: Session): + """Backdate validation is per-commodity: gas meter start does not constrain electricity.""" + # Declare gas meter at a later time. + declare_meter( + session, label="Gas 1", started_at=_T0 + timedelta(days=10), reason="initial", commodity="gas" + ) + session.commit() + + # Declaring an electricity meter at an earlier time must succeed (no gas constraint). + m = declare_meter( + session, label="Elec 1", started_at=_T0, reason="initial", commodity="electricity" + ) + session.commit() + assert m is not None + + +# --------------------------------------------------------------------------- +# 6. list_meters +# --------------------------------------------------------------------------- + + +class TestListMeters: + def test_returns_empty_list_when_no_meters(self, session: Session): + assert list_meters(session) == [] + + def test_ordered_by_started_at_asc(self, session: Session): + """list_meters must return meters in ascending started_at order.""" + t1 = _T0 + timedelta(days=10) + t2 = _T0 + timedelta(days=20) + declare_meter(session, label="M1", started_at=_T0, reason="initial") + session.commit() + declare_meter(session, label="M2", started_at=t1, reason="meter_swap") + session.commit() + declare_meter(session, label="M3", started_at=t2, reason="meter_swap") + session.commit() + + meters = list_meters(session, commodity="electricity") + assert [m.label for m in meters] == ["M1", "M2", "M3"] + + def test_commodity_filter_returns_only_matching(self, session: Session): + """list_meters with commodity kwarg must filter correctly.""" + declare_meter(session, label="Elec", started_at=_T0, reason="initial", commodity="electricity") + declare_meter(session, label="Gas", started_at=_T0, reason="initial", commodity="gas") + session.commit() + + elec = list_meters(session, commodity="electricity") + gas = list_meters(session, commodity="gas") + all_meters = list_meters(session) + + assert len(elec) == 1 and elec[0].commodity == "electricity" + assert len(gas) == 1 and gas[0].commodity == "gas" + assert len(all_meters) == 2 + + +# --------------------------------------------------------------------------- +# 7 & 8. update_meter +# --------------------------------------------------------------------------- + + +class TestUpdateMeter: + def test_update_label(self, session: Session): + """update_meter must update label without touching other fields.""" + m = _make_meter(session, started_at=_T0) + update_meter(session, m, label="New label") + session.commit() + + fetched = session.get(Meter, m.id) + assert fetched.label == "New label" + assert fetched.note is None # unchanged + + def test_update_note(self, session: Session): + """update_meter must update note without touching other fields.""" + m = _make_meter(session, started_at=_T0) + update_meter(session, m, note="Some note") + session.commit() + + fetched = session.get(Meter, m.id) + assert fetched.note == "Some note" + assert fetched.label == "Test meter" # unchanged + + def test_update_label_and_note_simultaneously(self, session: Session): + """update_meter must update both label and note in a single call.""" + m = _make_meter(session, started_at=_T0) + update_meter(session, m, label="Updated label", note="Updated note") + session.commit() + + fetched = session.get(Meter, m.id) + assert fetched.label == "Updated label" + assert fetched.note == "Updated note" + + def test_update_started_at_first_meter(self, session: Session): + """Changing started_at of the first (only) active meter must work without a previous meter.""" + m = _make_meter(session, started_at=_T0, ended_at=None) + new_start = _T0 + timedelta(hours=2) + + update_meter(session, m, started_at=new_start) + session.commit() + + fetched = session.get(Meter, m.id) + from app.services.meters import _as_utc + assert _as_utc(fetched.started_at) == _as_utc(new_start) + + def test_update_started_at_updates_previous_ended_at(self, session: Session): + """Retroactive started_at change must propagate to the previous meter's ended_at.""" + t1 = _T0 + timedelta(days=10) + prev = _make_meter(session, started_at=_T0, ended_at=t1, label="Prev meter") + curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr meter") + session.commit() + + new_start = _T0 + timedelta(days=7) # shift boundary 3 days earlier + update_meter(session, curr, started_at=new_start) + session.commit() + + from app.services.meters import _as_utc + fetched_prev = session.get(Meter, prev.id) + fetched_curr = session.get(Meter, curr.id) + + # The previous meter's ended_at must now equal the new started_at. + assert _as_utc(fetched_prev.ended_at) == _as_utc(new_start), ( + "Previous meter's ended_at must be updated to maintain continuity" + ) + # The current meter's started_at must reflect the change. + assert _as_utc(fetched_curr.started_at) == _as_utc(new_start) + + def test_update_started_at_continuity_maintained(self, session: Session): + """After retroactive started_at change, prev.ended_at == curr.started_at (no gap).""" + t1 = _T0 + timedelta(days=10) + prev = _make_meter(session, started_at=_T0, ended_at=t1, label="Prev") + curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr") + session.commit() + + new_start = _T0 + timedelta(days=12) # shift boundary 2 days later + update_meter(session, curr, started_at=new_start) + session.commit() + + from app.services.meters import _as_utc + fetched_prev = session.get(Meter, prev.id) + fetched_curr = session.get(Meter, curr.id) + + assert _as_utc(fetched_prev.ended_at) == _as_utc(fetched_curr.started_at), ( + "Timeline must remain contiguous after retroactive started_at shift" + ) + + def test_update_started_at_rejects_at_or_past_ended_at(self, session: Session): + """New started_at ≥ ended_at must raise MeterIntervalError (empty/inverted epoch).""" + t1 = _T0 + timedelta(days=10) + m = _make_meter(session, started_at=_T0, ended_at=t1) + + with pytest.raises(MeterIntervalError): + update_meter(session, m, started_at=t1) # == ended_at → empty epoch + + with pytest.raises(MeterIntervalError): + update_meter(session, m, started_at=t1 + timedelta(hours=1)) # > ended_at + + def test_update_started_at_rejects_at_or_before_prev_started_at(self, session: Session): + """New started_at ≤ prev.started_at must raise MeterIntervalError.""" + t1 = _T0 + timedelta(days=10) + _make_meter(session, started_at=_T0, ended_at=t1, label="Prev") + curr = _make_meter(session, started_at=t1, ended_at=None, label="Curr") + session.commit() + + with pytest.raises(MeterIntervalError): + # exactly at prev.started_at — would collapse prev epoch to zero + update_meter(session, curr, started_at=_T0) + + with pytest.raises(MeterIntervalError): + # strictly before prev.started_at — inverts prev epoch + update_meter(session, curr, started_at=_T0 - timedelta(hours=1)) + + def test_noop_call_does_not_raise(self, session: Session): + """Calling update_meter with all None args must succeed without error.""" + m = _make_meter(session, started_at=_T0) + result = update_meter(session, m) + assert result is m + + def test_update_started_at_no_previous_meter_shift_backward(self, session: Session): + """For the first meter (no previous), shifting started_at backward must succeed.""" + m = _make_meter(session, started_at=_T0, ended_at=None) + earlier = _T0 - timedelta(days=5) + update_meter(session, m, started_at=earlier) + session.commit() + + from app.services.meters import _as_utc + fetched = session.get(Meter, m.id) + assert _as_utc(fetched.started_at) == _as_utc(earlier)