feat(backend): expose GET /api/v1/readings avec fenetre bornee et pagination

This commit is contained in:
Dorian
2026-09-17 11:50:20 +02:00
parent 63ee79cf32
commit 8d28113f03
15 changed files with 1097 additions and 20 deletions
+1
View File
@@ -35,6 +35,7 @@ ROUTES_A_ROLE = {
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
("GET", "/api/v1/readings"),
}
+198
View File
@@ -0,0 +1,198 @@
from collections.abc import Callable, Iterator
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi import FastAPI
from httpx import AsyncClient
from app.api.deps import get_current_principal, get_reading_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Reading
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
def principal(role: Role = Role.LECTEUR) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading:
return Reading(
reading_id=reading_id,
site_id=site_id,
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
source="api_current",
consumption_kw=42.5,
consumption_kwh=None,
consumption_euros=None,
voltage_v=230.0,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
solar_irradiance_wm2=None,
is_working_hours=True,
data_quality="good",
null_reasons=None,
imputed_values=None,
imputation_method=None,
raw_data={},
)
class FauxService:
def __init__(self, leve: Exception | None = None) -> None:
self.reading = reading()
self.leve = leve
self.appels: list[tuple[str | None, str | None, str | None, int, int]] = []
async def list_history(
self,
*,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int,
offset: int,
) -> list[Reading]:
self.appels.append((site_id, start, end, limit, offset))
if self.leve is not None:
raise self.leve
return [self.reading]
@pytest.fixture
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
app.dependency_overrides[get_current_principal] = lambda: principal()
yield
app.dependency_overrides.pop(get_current_principal, None)
@pytest.fixture
def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[..., FauxService]]:
def installe(*, leve: Exception | None = None) -> FauxService:
service = FauxService(leve=leve)
app.dependency_overrides[get_reading_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_reading_service, None)
async def test_list_readings_returns_the_readings(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"reading_id": 1,
"site_id": "site-1",
"timestamp": "2026-09-16T00:00:00Z",
"source": "api_current",
"consumption_kw": 42.5,
"consumption_kwh": None,
"consumption_euros": None,
"voltage_v": 230.0,
"current_a": None,
"power_factor": None,
"temperature_celsius": None,
"humidity_percent": None,
"solar_irradiance_wm2": None,
"is_working_hours": True,
"data_quality": "good",
"null_reasons": None,
"imputed_values": None,
"imputation_method": None,
}
]
async def test_list_readings_transmits_the_filters_and_pagination(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
service = servi()
response = await client.get(
"/api/v1/readings",
params={
"site_id": "site-1",
"start": "2026-09-01T00:00:00Z",
"end": "2026-09-02T00:00:00Z",
"limit": 50,
"offset": 10,
},
)
assert response.status_code == 200
assert service.appels == [
(
"site-1",
datetime(2026, 9, 1, tzinfo=UTC),
datetime(2026, 9, 2, tzinfo=UTC),
50,
10,
)
]
async def test_list_readings_returns_400_when_the_window_is_inverted(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(leve=FenetreInverseeError())
response = await client.get("/api/v1/readings")
assert response.status_code == 400
async def test_list_readings_returns_400_when_the_window_is_too_large(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(leve=FenetreTropLargeError())
response = await client.get("/api/v1/readings")
assert response.status_code == 400
async def test_list_readings_returns_422_for_a_limit_above_the_maximum(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings", params={"limit": 5000})
assert response.status_code == 422
async def test_list_readings_returns_422_for_a_negative_offset(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings", params={"offset": -1})
assert response.status_code == 422
async def test_list_readings_returns_an_empty_list_when_there_is_nothing(
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=[])
response = await client.get("/api/v1/readings")
assert response.status_code == 200
assert response.json() == []