From 839faaf95bb1a31c017ccb9e8e242d4660f772b2 Mon Sep 17 00:00:00 2001 From: Tianyu Liu Date: Thu, 25 Jun 2026 16:29:09 +0200 Subject: [PATCH] M7-T05: add meter CRUD API + retroactive recompute + OpenAPI regen --- app/api/routes/api/meters.py | 302 ++++++++++++++++ app/main.py | 2 + app/schemas/meter.py | 131 +++++++ openapi/openapi.json | 341 ++++++++++++++++++ openapi/openapi.yaml | 308 ++++++++++++++++ tests/test_api_meters.py | 662 +++++++++++++++++++++++++++++++++++ 6 files changed, 1746 insertions(+) create mode 100644 app/api/routes/api/meters.py create mode 100644 app/schemas/meter.py create mode 100644 tests/test_api_meters.py diff --git a/app/api/routes/api/meters.py b/app/api/routes/api/meters.py new file mode 100644 index 0000000..b92e1a3 --- /dev/null +++ b/app/api/routes/api/meters.py @@ -0,0 +1,302 @@ +"""Meter CRUD, swap declaration, and retroactive recompute API (M7-T05). + +All endpoints are under /api/energy/meters, require an authenticated session, +and write endpoints (POST/PATCH) additionally require a non-empty X-CSRF-Token +header. + +Route semantics +--------------- +GET /api/energy/meters — list all meter epochs (ascending started_at) +POST /api/energy/meters — declare a meter swap / initial meter epoch +PATCH /api/energy/meters/{id} — edit label / note, or correct started_at (retroactive) + +Retroactive recompute +--------------------- +Whenever a write operation changes a meter's ``started_at`` (new declaration +or PATCH correction), the affected billing window is re-judged via +``recompute_range``: + +- **POST** (new meter, possibly retroactive): + window = [new_meter.started_at, now) + Rationale: the new meter's ``started_at`` closes the previous meter at that + point; all periods from that boundary forward may have a different meter + attribution. Using ``now`` as the upper bound is safe because + ``recompute_range`` only processes closed quarters and the operation is + idempotent. + +- **PATCH started_at** (retroactive correction): + window = [min(old_started_at, new_started_at), now) + Rationale: shifting the boundary in either direction affects all periods + between the old and new boundary (and potentially beyond if re-attribution + cascades). Using the minimum of the two timestamps guarantees the entire + affected range is covered; using ``now`` as the upper bound is safe and + idempotent. + +``started_at`` localisation (Principle A, FU10 convention) +---------------------------------------------------------- +If the client sends a timezone-naive ``started_at`` value, it is interpreted as +the **server's local wall-clock time** and converted to UTC before storage. +Timezone-aware values are converted to UTC as-is. This is identical to the +``_localize_effective_from`` convention used in ``energy_contracts.py``. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.routes.api.deps import require_csrf, require_session +from app.dependencies import get_db +from app.models.energy import Meter +from app.schemas.meter import ( + MeterDeclareRequest, + MeterListResponse, + MeterPatchRequest, + MeterResponse, +) +from app.services import timezone as _tz_mod +from app.services.auth import AuthenticatedSession +from app.services.energy_cost import recompute_range +from app.services.meters import ( + MeterIntervalError, + MeterOverlapError, + declare_meter, + list_meters, + update_meter, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/energy", tags=["api-energy-meters"]) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_meter_or_404(db: Session, meter_id: int) -> Meter: + """Return the meter with the given id or raise 404.""" + meter: Optional[Meter] = db.get(Meter, meter_id) + if meter is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Meter {meter_id!r} not found.", + ) + return meter + + +def _localize_started_at(dt: datetime) -> datetime: + """Resolve *dt* to an aware UTC datetime for storage. + + Follows the same Principle-A convention as ``_localize_effective_from`` + in ``energy_contracts.py`` (FU10): + + - Timezone-aware → convert to UTC as-is. + - Timezone-naive → interpret as server local wall-clock time, localize + with ``local_tz()``, then convert to UTC. + + A front-end sending ``"2026-06-25T00:00:00"`` (no Z) has it interpreted + as local midnight (e.g. CEST = UTC+2 → stored as 2026-06-24T22:00:00Z), + not as UTC midnight. + """ + if dt.tzinfo is not None: + return dt.astimezone(UTC) + tz = _tz_mod.local_tz() + local_dt = dt.replace(tzinfo=tz) + return local_dt.astimezone(UTC) + + +def _trigger_recompute(db: Session, start: datetime, label: str) -> int: + """Trigger recompute_range from *start* to now (UTC). + + This is the standard "retroactive window" call: everything from the + affected boundary up to the current moment needs re-attribution. + Using ``now`` as the upper bound is safe because ``recompute_range`` + only touches closed quarter-hour periods and the operation is idempotent. + """ + end = datetime.now(UTC) + if start >= end: + # started_at is in the future — nothing to recompute. + logger.info("%s: started_at (%s) is in the future, skipping recompute.", label, start) + return 0 + n = recompute_range(db, start, end) + logger.info( + "%s: recomputed %d period(s) in window [%s, %s).", + label, + n, + start.isoformat(), + end.isoformat(), + ) + return n + + +# --------------------------------------------------------------------------- +# GET /api/energy/meters +# --------------------------------------------------------------------------- + + +@router.get("/meters", response_model=MeterListResponse) +def list_energy_meters( + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), +) -> MeterListResponse: + """List all meter epochs in ascending ``started_at`` order. + + Returns the full historical sequence of meter installations across all + commodities. The active meter (``ended_at=null``) appears last because it + has the latest ``started_at``. + """ + meters = list_meters(db) + items = [MeterResponse.model_validate(m) for m in meters] + return MeterListResponse(items=items, total=len(items)) + + +# --------------------------------------------------------------------------- +# POST /api/energy/meters +# --------------------------------------------------------------------------- + + +@router.post( + "/meters", + response_model=MeterResponse, + status_code=status.HTTP_201_CREATED, +) +def declare_energy_meter( + body: MeterDeclareRequest, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> MeterResponse: + """Declare a new meter epoch (swap, home move, or initial declaration). + + Closes the current active meter for the given commodity at ``started_at`` + and opens a new active meter. If no active meter exists, the new meter is + simply created without closing anything. + + **Validation**: ``started_at`` must be **≥** the current active meter's + own ``started_at`` (no chronological backdate below the active epoch's + start). Equal timestamps are allowed (replaces the current meter at the + same logical moment). Violation → 422. + + **Retroactive recompute**: if ``started_at`` is in the past, billing + records from that point forward are re-judged via ``recompute_range`` to + reflect the new meter attribution. The response includes the count of + recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse`` + — the recompute is transparent; callers should re-fetch costs if needed). + """ + started_at_utc = _localize_started_at(body.started_at) + + try: + new_meter = declare_meter( + db, + label=body.label, + started_at=started_at_utc, + reason=body.reason.value, + commodity=body.commodity, + note=body.note, + ) + except MeterOverlapError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) + + db.flush() # assign PK before recompute (recompute uses session, needs meter in DB) + + # Retroactive recompute: re-judge attribution from the new boundary onward. + now = datetime.now(UTC) + if started_at_utc < now: + _trigger_recompute(db, started_at_utc, "POST /api/energy/meters") + + db.commit() + db.refresh(new_meter) + + logger.info( + "POST /api/energy/meters: declared %r meter id=%d label=%r started_at=%s", + body.commodity, + new_meter.id, + new_meter.label, + started_at_utc.isoformat(), + ) + return MeterResponse.model_validate(new_meter) + + +# --------------------------------------------------------------------------- +# PATCH /api/energy/meters/{id} +# --------------------------------------------------------------------------- + + +@router.patch("/meters/{meter_id}", response_model=MeterResponse) +def patch_energy_meter( + meter_id: int, + body: MeterPatchRequest, + db: Session = Depends(get_db), + _auth: AuthenticatedSession = Depends(require_session), + _csrf: None = Depends(require_csrf), +) -> MeterResponse: + """Partially update a meter epoch: rename, edit note, or correct started_at. + + - ``label``: updates the human-readable label. + - ``note``: updates the free-form note. + - ``started_at``: **retroactive correction** — shifts this meter's start + boundary. The service layer maintains timeline continuity by also + updating the preceding meter's ``ended_at``. Validation: + * Must be strictly after the previous meter's own ``started_at``. + * Must be strictly before this meter's ``ended_at`` (if closed). + Violation → 422. + + **Retroactive recompute when ``started_at`` changes**: billing records in + the window ``[min(old, new), now)`` are re-judged to reflect the corrected + meter attribution. + + Not found → 404. + """ + meter = _get_meter_or_404(db, meter_id) + + # Capture old started_at before mutation (needed for recompute window). + old_started_at: Optional[datetime] = meter.started_at + + # Localise started_at if provided. + new_started_at_utc: Optional[datetime] = None + if body.started_at is not None: + new_started_at_utc = _localize_started_at(body.started_at) + + try: + update_meter( + db, + meter, + label=body.label, + note=body.note, + started_at=new_started_at_utc, + ) + except MeterIntervalError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) + + # Retroactive recompute if started_at was changed. + if new_started_at_utc is not None and old_started_at is not None: + # Normalise old_started_at to UTC-aware for comparison. + if old_started_at.tzinfo is None: + old_started_at = old_started_at.replace(tzinfo=UTC) + # Window = [min(old, new), now) — covers all periods whose attribution + # may have changed due to the boundary shift in either direction. + window_start = min(old_started_at, new_started_at_utc) + _trigger_recompute(db, window_start, f"PATCH /api/energy/meters/{meter_id}") + + db.commit() + db.refresh(meter) + + logger.info( + "PATCH /api/energy/meters/%d: updated meter label=%r started_at=%s", + meter_id, + meter.label, + meter.started_at, + ) + return MeterResponse.model_validate(meter) diff --git a/app/main.py b/app/main.py index 4853af6..4c1573d 100644 --- a/app/main.py +++ b/app/main.py @@ -16,6 +16,7 @@ from app.api.routes.api.data import router as api_data_router from app.api.routes.api.energy import router as api_energy_router from app.api.routes.api.energy_contracts import router as api_energy_contracts_router from app.api.routes.api.expose import router as api_expose_router +from app.api.routes.api.meters import router as api_meters_router from app.api.routes.api.modbus import router as api_modbus_router from app.api.routes.api.session import router as api_session_router from app.api.routes import status @@ -280,6 +281,7 @@ def create_app() -> FastAPI: app.include_router(api_data_router) app.include_router(api_energy_router) app.include_router(api_energy_contracts_router) + app.include_router(api_meters_router) app.include_router(api_expose_router) app.include_router(api_modbus_router) app.include_router(api_session_router) diff --git a/app/schemas/meter.py b/app/schemas/meter.py new file mode 100644 index 0000000..6a79c37 --- /dev/null +++ b/app/schemas/meter.py @@ -0,0 +1,131 @@ +"""Pydantic schemas for the Meter CRUD + swap declaration API (M7-T05). + +Schema hierarchy +---------------- +MeterResponse — single meter row (id/label/commodity/started_at/ended_at/reason/note/created_at) +MeterListResponse — ordered list of MeterResponse items +MeterDeclareRequest — POST /api/energy/meters body (declare a swap or initial meter) +MeterPatchRequest — PATCH /api/energy/meters/{id} body (all fields optional) +""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class MeterReason(str, Enum): + """Allowed values for the meter epoch creation reason.""" + + initial = "initial" + meter_swap = "meter_swap" + home_move = "home_move" + other = "other" + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + + +class MeterResponse(BaseModel): + """Response schema for a single Meter epoch row. + + ``ended_at`` is ``null`` for the currently active meter. + """ + + id: int + label: str + commodity: str + started_at: datetime + ended_at: datetime | None + reason: str + note: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class MeterListResponse(BaseModel): + """Response schema for GET /api/energy/meters. + + Meters are returned in ascending ``started_at`` order so the caller sees + the historical installation sequence. + """ + + items: list[MeterResponse] + total: int + + +# --------------------------------------------------------------------------- +# Request schemas +# --------------------------------------------------------------------------- + +_VALID_REASONS = ", ".join(r.value for r in MeterReason) + + +class MeterDeclareRequest(BaseModel): + """Request body for POST /api/energy/meters. + + Declares a new meter epoch (swap, home move, or initial declaration). The + service layer closes the current active meter for the given commodity at + ``started_at`` and opens a new one. + + ``started_at`` follows the Principle-A localisation convention: a + timezone-naive value is interpreted as the **server's local wall-clock time** + (e.g. CEST midnight → stored as UTC the night before); a timezone-aware + value is converted to UTC as-is. Omitting ``started_at`` is not allowed — + every meter declaration must carry an explicit start timestamp. + + ``commodity`` defaults to ``"electricity"``; the field is available for + future use with ``gas`` or ``heating``. + """ + + label: str = Field(..., min_length=1, max_length=255) + started_at: datetime = Field( + ..., + description=( + "UTC (or server-local naive) datetime from which this meter epoch starts. " + "May be in the past (retroactive declaration)." + ), + ) + reason: MeterReason = Field( + ..., + description=f"Why this epoch was created. One of: {_VALID_REASONS}.", + ) + note: str | None = Field(default=None, max_length=1024) + commodity: str = Field( + default="electricity", + min_length=1, + max_length=32, + description="Energy commodity this meter measures. Defaults to 'electricity'.", + ) + + +class MeterPatchRequest(BaseModel): + """Request body for PATCH /api/energy/meters/{id}. + + All fields are optional. Only non-``None`` values are applied. + + Updating ``started_at`` is a **retroactive correction**: the service layer + maintains timeline continuity (adjusting the preceding meter's ``ended_at``) + and the API layer triggers ``recompute_range`` over the affected window so + that billing attribution is re-judged. + """ + + label: str | None = Field(default=None, min_length=1, max_length=255) + note: str | None = Field(default=None, max_length=1024) + started_at: datetime | None = Field( + default=None, + description=( + "Retroactive correction of the meter epoch start timestamp. " + "Triggers billing recompute over the affected window." + ), + ) diff --git a/openapi/openapi.json b/openapi/openapi.json index 90ea373..b544fbf 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -1379,6 +1379,155 @@ } } }, + "/api/energy/meters": { + "get": { + "tags": [ + "api-energy-meters" + ], + "summary": "List Energy Meters", + "description": "List all meter epochs in ascending ``started_at`` order.\n\nReturns the full historical sequence of meter installations across all\ncommodities. The active meter (``ended_at=null``) appears last because it\nhas the latest ``started_at``.", + "operationId": "list_energy_meters_api_energy_meters_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterListResponse" + } + } + } + } + } + }, + "post": { + "tags": [ + "api-energy-meters" + ], + "summary": "Declare Energy Meter", + "description": "Declare a new meter epoch (swap, home move, or initial declaration).\n\nCloses the current active meter for the given commodity at ``started_at``\nand opens a new active meter. If no active meter exists, the new meter is\nsimply created without closing anything.\n\n**Validation**: ``started_at`` must be **≥** the current active meter's\nown ``started_at`` (no chronological backdate below the active epoch's\nstart). Equal timestamps are allowed (replaces the current meter at the\nsame logical moment). Violation → 422.\n\n**Retroactive recompute**: if ``started_at`` is in the past, billing\nrecords from that point forward are re-judged via ``recompute_range`` to\nreflect the new meter attribution. The response includes the count of\nrecomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``\n— the recompute is transparent; callers should re-fetch costs if needed).", + "operationId": "declare_energy_meter_api_energy_meters_post", + "parameters": [ + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterDeclareRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/energy/meters/{meter_id}": { + "patch": { + "tags": [ + "api-energy-meters" + ], + "summary": "Patch Energy Meter", + "description": "Partially update a meter epoch: rename, edit note, or correct started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``: updates the free-form note.\n- ``started_at``: **retroactive correction** — shifts this meter's start\n boundary. The service layer maintains timeline continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n * Must be strictly after the previous meter's own ``started_at``.\n * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing records in\nthe window ``[min(old, new), now)`` are re-judged to reflect the corrected\nmeter attribution.\n\nNot found → 404.", + "operationId": "patch_energy_meter_api_energy_meters__meter_id__patch", + "parameters": [ + { + "name": "meter_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Meter Id" + } + }, + { + "name": "X-CSRF-Token", + "in": "header", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X-Csrf-Token" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterPatchRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MeterResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/expose": { "get": { "tags": [ @@ -3334,6 +3483,198 @@ "title": "ManualTariffSchema", "description": "Fixed-tariff breakdown for manual contracts.\n\nPrices are the *effective* buy prices as used by the billing engine\n(energy_buy_x + energy_tax + ode) and the raw sell prices." }, + "MeterDeclareRequest": { + "properties": { + "label": { + "type": "string", + "maxLength": 255, + "minLength": 1, + "title": "Label" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At", + "description": "UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration)." + }, + "reason": { + "$ref": "#/components/schemas/MeterReason", + "description": "Why this epoch was created. One of: initial, meter_swap, home_move, other." + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "commodity": { + "type": "string", + "maxLength": 32, + "minLength": 1, + "title": "Commodity", + "description": "Energy commodity this meter measures. Defaults to 'electricity'.", + "default": "electricity" + } + }, + "type": "object", + "required": [ + "label", + "started_at", + "reason" + ], + "title": "MeterDeclareRequest", + "description": "Request body for POST /api/energy/meters.\n\nDeclares a new meter epoch (swap, home move, or initial declaration). The\nservice layer closes the current active meter for the given commodity at\n``started_at`` and opens a new one.\n\n``started_at`` follows the Principle-A localisation convention: a\ntimezone-naive value is interpreted as the **server's local wall-clock time**\n(e.g. CEST midnight → stored as UTC the night before); a timezone-aware\nvalue is converted to UTC as-is. Omitting ``started_at`` is not allowed —\nevery meter declaration must carry an explicit start timestamp.\n\n``commodity`` defaults to ``\"electricity\"``; the field is available for\nfuture use with ``gas`` or ``heating``." + }, + "MeterListResponse": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/MeterResponse" + }, + "type": "array", + "title": "Items" + }, + "total": { + "type": "integer", + "title": "Total" + } + }, + "type": "object", + "required": [ + "items", + "total" + ], + "title": "MeterListResponse", + "description": "Response schema for GET /api/energy/meters.\n\nMeters are returned in ascending ``started_at`` order so the caller sees\nthe historical installation sequence." + }, + "MeterPatchRequest": { + "properties": { + "label": { + "anyOf": [ + { + "type": "string", + "maxLength": 255, + "minLength": 1 + }, + { + "type": "null" + } + ], + "title": "Label" + }, + "note": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "started_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Started At", + "description": "Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window." + } + }, + "type": "object", + "title": "MeterPatchRequest", + "description": "Request body for PATCH /api/energy/meters/{id}.\n\nAll fields are optional. Only non-``None`` values are applied.\n\nUpdating ``started_at`` is a **retroactive correction**: the service layer\nmaintains timeline continuity (adjusting the preceding meter's ``ended_at``)\nand the API layer triggers ``recompute_range`` over the affected window so\nthat billing attribution is re-judged." + }, + "MeterReason": { + "type": "string", + "enum": [ + "initial", + "meter_swap", + "home_move", + "other" + ], + "title": "MeterReason", + "description": "Allowed values for the meter epoch creation reason." + }, + "MeterResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id" + }, + "label": { + "type": "string", + "title": "Label" + }, + "commodity": { + "type": "string", + "title": "Commodity" + }, + "started_at": { + "type": "string", + "format": "date-time", + "title": "Started At" + }, + "ended_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Ended At" + }, + "reason": { + "type": "string", + "title": "Reason" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Note" + }, + "created_at": { + "type": "string", + "format": "date-time", + "title": "Created At" + } + }, + "type": "object", + "required": [ + "id", + "label", + "commodity", + "started_at", + "ended_at", + "reason", + "note", + "created_at" + ], + "title": "MeterResponse", + "description": "Response schema for a single Meter epoch row.\n\n``ended_at`` is ``null`` for the currently active meter." + }, "MetricInfo": { "properties": { "key": { diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 15241ae..27d1976 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1077,6 +1077,138 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/energy/meters: + get: + tags: + - api-energy-meters + summary: List Energy Meters + description: 'List all meter epochs in ascending ``started_at`` order. + + + Returns the full historical sequence of meter installations across all + + commodities. The active meter (``ended_at=null``) appears last because it + + has the latest ``started_at``.' + operationId: list_energy_meters_api_energy_meters_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterListResponse' + post: + tags: + - api-energy-meters + summary: Declare Energy Meter + description: 'Declare a new meter epoch (swap, home move, or initial declaration). + + + Closes the current active meter for the given commodity at ``started_at`` + + and opens a new active meter. If no active meter exists, the new meter is + + simply created without closing anything. + + + **Validation**: ``started_at`` must be **≥** the current active meter''s + + own ``started_at`` (no chronological backdate below the active epoch''s + + start). Equal timestamps are allowed (replaces the current meter at the + + same logical moment). Violation → 422. + + + **Retroactive recompute**: if ``started_at`` is in the past, billing + + records from that point forward are re-judged via ``recompute_range`` to + + reflect the new meter attribution. The response includes the count of + + recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse`` + + — the recompute is transparent; callers should re-fetch costs if needed).' + operationId: declare_energy_meter_api_energy_meters_post + parameters: + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MeterDeclareRequest' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/energy/meters/{meter_id}: + patch: + tags: + - api-energy-meters + summary: Patch Energy Meter + description: "Partially update a meter epoch: rename, edit note, or correct\ + \ started_at.\n\n- ``label``: updates the human-readable label.\n- ``note``:\ + \ updates the free-form note.\n- ``started_at``: **retroactive correction**\ + \ — shifts this meter's start\n boundary. The service layer maintains timeline\ + \ continuity by also\n updating the preceding meter's ``ended_at``. Validation:\n\ + \ * Must be strictly after the previous meter's own ``started_at``.\n \ + \ * Must be strictly before this meter's ``ended_at`` (if closed).\n Violation\ + \ → 422.\n\n**Retroactive recompute when ``started_at`` changes**: billing\ + \ records in\nthe window ``[min(old, new), now)`` are re-judged to reflect\ + \ the corrected\nmeter attribution.\n\nNot found → 404." + operationId: patch_energy_meter_api_energy_meters__meter_id__patch + parameters: + - name: meter_id + in: path + required: true + schema: + type: integer + title: Meter Id + - name: X-CSRF-Token + in: header + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: X-Csrf-Token + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MeterPatchRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/MeterResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/expose: get: tags: @@ -2595,6 +2727,182 @@ components: Prices are the *effective* buy prices as used by the billing engine (energy_buy_x + energy_tax + ode) and the raw sell prices.' + MeterDeclareRequest: + properties: + label: + type: string + maxLength: 255 + minLength: 1 + title: Label + started_at: + type: string + format: date-time + title: Started At + description: UTC (or server-local naive) datetime from which this meter + epoch starts. May be in the past (retroactive declaration). + reason: + $ref: '#/components/schemas/MeterReason' + description: 'Why this epoch was created. One of: initial, meter_swap, + home_move, other.' + note: + anyOf: + - type: string + maxLength: 1024 + - type: 'null' + title: Note + commodity: + type: string + maxLength: 32 + minLength: 1 + title: Commodity + description: Energy commodity this meter measures. Defaults to 'electricity'. + default: electricity + type: object + required: + - label + - started_at + - reason + title: MeterDeclareRequest + description: 'Request body for POST /api/energy/meters. + + + Declares a new meter epoch (swap, home move, or initial declaration). The + + service layer closes the current active meter for the given commodity at + + ``started_at`` and opens a new one. + + + ``started_at`` follows the Principle-A localisation convention: a + + timezone-naive value is interpreted as the **server''s local wall-clock time** + + (e.g. CEST midnight → stored as UTC the night before); a timezone-aware + + value is converted to UTC as-is. Omitting ``started_at`` is not allowed — + + every meter declaration must carry an explicit start timestamp. + + + ``commodity`` defaults to ``"electricity"``; the field is available for + + future use with ``gas`` or ``heating``.' + MeterListResponse: + properties: + items: + items: + $ref: '#/components/schemas/MeterResponse' + type: array + title: Items + total: + type: integer + title: Total + type: object + required: + - items + - total + title: MeterListResponse + description: 'Response schema for GET /api/energy/meters. + + + Meters are returned in ascending ``started_at`` order so the caller sees + + the historical installation sequence.' + MeterPatchRequest: + properties: + label: + anyOf: + - type: string + maxLength: 255 + minLength: 1 + - type: 'null' + title: Label + note: + anyOf: + - type: string + maxLength: 1024 + - type: 'null' + title: Note + started_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Started At + description: Retroactive correction of the meter epoch start timestamp. Triggers + billing recompute over the affected window. + type: object + title: MeterPatchRequest + description: 'Request body for PATCH /api/energy/meters/{id}. + + + All fields are optional. Only non-``None`` values are applied. + + + Updating ``started_at`` is a **retroactive correction**: the service layer + + maintains timeline continuity (adjusting the preceding meter''s ``ended_at``) + + and the API layer triggers ``recompute_range`` over the affected window so + + that billing attribution is re-judged.' + MeterReason: + type: string + enum: + - initial + - meter_swap + - home_move + - other + title: MeterReason + description: Allowed values for the meter epoch creation reason. + MeterResponse: + properties: + id: + type: integer + title: Id + label: + type: string + title: Label + commodity: + type: string + title: Commodity + started_at: + type: string + format: date-time + title: Started At + ended_at: + anyOf: + - type: string + format: date-time + - type: 'null' + title: Ended At + reason: + type: string + title: Reason + note: + anyOf: + - type: string + - type: 'null' + title: Note + created_at: + type: string + format: date-time + title: Created At + type: object + required: + - id + - label + - commodity + - started_at + - ended_at + - reason + - note + - created_at + title: MeterResponse + description: 'Response schema for a single Meter epoch row. + + + ``ended_at`` is ``null`` for the currently active meter.' MetricInfo: properties: key: diff --git a/tests/test_api_meters.py b/tests/test_api_meters.py new file mode 100644 index 0000000..07a144e --- /dev/null +++ b/tests/test_api_meters.py @@ -0,0 +1,662 @@ +"""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 (integration: no recompute mock) +# --------------------------------------------------------------------------- + + +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: integration (no mock) — 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