M7-T02: add meter service layer (declare/swap/close/edit, meter_at, mutual exclusion)
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user