Merge pull request #94 from ineszang/feat/get-readings
feat(backend): expose GET /api/v1/readings avec fenetre bornee et pag…
This commit is contained in:
@@ -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"),
|
||||
("GET", "/api/v1/sensors/status"),
|
||||
}
|
||||
|
||||
|
||||
@@ -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() == []
|
||||
@@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Reading, Site
|
||||
from app.repositories.reading import ReadingRepository
|
||||
from tests.repositories.test_site import creer as creer_site
|
||||
from tests.repositories.test_site import identifiant as identifiant_site
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
@@ -25,6 +27,20 @@ def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Read
|
||||
)
|
||||
|
||||
|
||||
async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: object) -> Reading:
|
||||
reading = Reading(
|
||||
site_id=site_id,
|
||||
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
||||
source=overrides.get("source", "api_current"),
|
||||
consumption_kw=overrides.get("consumption_kw", 10.0),
|
||||
data_quality=overrides.get("data_quality", "good"),
|
||||
raw_data=overrides.get("raw_data", {}),
|
||||
)
|
||||
session.add(reading)
|
||||
await session.flush()
|
||||
return reading
|
||||
|
||||
|
||||
async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None:
|
||||
site_id = identifiant()
|
||||
maintenant = datetime.now(UTC)
|
||||
@@ -70,3 +86,110 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == {premier, second}
|
||||
|
||||
|
||||
async def test_list_history_orders_the_readings_by_timestamp_descending(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
ancienne = await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)
|
||||
)
|
||||
recente = await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
|
||||
)
|
||||
|
||||
resultats = await depot.list_history(
|
||||
start=datetime(2026, 8, 1, tzinfo=UTC),
|
||||
end=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
limit=100,
|
||||
offset=0,
|
||||
)
|
||||
identifiants = [
|
||||
r.reading_id for r in resultats if r.reading_id in (ancienne.reading_id, recente.reading_id)
|
||||
]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [recente.reading_id, ancienne.reading_id]
|
||||
|
||||
|
||||
async def test_list_history_filters_by_site_id(session: AsyncSession) -> None:
|
||||
premier = await creer_site(session)
|
||||
second = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
voulue = await creer_lecture(session, site_id=premier.site_id)
|
||||
await creer_lecture(session, site_id=second.site_id)
|
||||
|
||||
resultats = await depot.list_history(
|
||||
site_id=premier.site_id,
|
||||
start=datetime(2026, 8, 1, tzinfo=UTC),
|
||||
end=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
limit=100,
|
||||
offset=0,
|
||||
)
|
||||
identifiants = [r.reading_id for r in resultats]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [voulue.reading_id]
|
||||
|
||||
|
||||
async def test_list_history_excludes_readings_outside_the_window(session: AsyncSession) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
dedans = await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, 10, tzinfo=UTC)
|
||||
)
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 8, 1, tzinfo=UTC))
|
||||
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 10, 1, tzinfo=UTC))
|
||||
|
||||
resultats = await depot.list_history(
|
||||
site_id=site.site_id,
|
||||
start=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
end=datetime(2026, 9, 30, tzinfo=UTC),
|
||||
limit=100,
|
||||
offset=0,
|
||||
)
|
||||
identifiants = [r.reading_id for r in resultats]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [dedans.reading_id]
|
||||
|
||||
|
||||
async def test_list_history_respects_limit_and_offset(session: AsyncSession) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = ReadingRepository(session)
|
||||
lectures = [
|
||||
await creer_lecture(
|
||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, jour, tzinfo=UTC)
|
||||
)
|
||||
for jour in (1, 2, 3)
|
||||
]
|
||||
|
||||
resultats = await depot.list_history(
|
||||
site_id=site.site_id,
|
||||
start=datetime(2026, 8, 1, tzinfo=UTC),
|
||||
end=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
limit=1,
|
||||
offset=1,
|
||||
)
|
||||
identifiants = [r.reading_id for r in resultats]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [lectures[1].reading_id]
|
||||
|
||||
|
||||
async def test_list_history_returns_an_empty_list_when_there_is_nothing(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = ReadingRepository(session)
|
||||
|
||||
resultats = await depot.list_history(
|
||||
site_id=identifiant_site(),
|
||||
start=datetime(2026, 8, 1, tzinfo=UTC),
|
||||
end=datetime(2026, 10, 1, tzinfo=UTC),
|
||||
limit=100,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
assert list(resultats) == []
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models.energy import Reading
|
||||
from app.services.reading import (
|
||||
FENETRE_MAXIMALE,
|
||||
FENETRE_PAR_DEFAUT,
|
||||
FenetreInverseeError,
|
||||
FenetreTropLargeError,
|
||||
ReadingService,
|
||||
)
|
||||
|
||||
|
||||
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=10.0,
|
||||
data_quality="good",
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, readings: list[Reading]) -> None:
|
||||
self._readings = readings
|
||||
self.appels: list[tuple[str | None, datetime, datetime, int, int]] = []
|
||||
|
||||
async def list_history(
|
||||
self,
|
||||
*,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
site_id: str | None = None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[Reading]:
|
||||
self.appels.append((site_id, start, end, limit, offset))
|
||||
return self._readings
|
||||
|
||||
|
||||
async def test_list_history_returns_the_repository_readings() -> None:
|
||||
service = ReadingService(readings=FakeRepository([reading(1), reading(2)]))
|
||||
|
||||
lectures = await service.list_history(limit=500, offset=0)
|
||||
|
||||
assert [r.reading_id for r in lectures] == [1, 2]
|
||||
|
||||
|
||||
async def test_list_history_relays_the_site_id_limit_and_offset() -> None:
|
||||
depot = FakeRepository([])
|
||||
service = ReadingService(readings=depot)
|
||||
debut = datetime(2026, 9, 1, tzinfo=UTC)
|
||||
fin = datetime(2026, 9, 2, tzinfo=UTC)
|
||||
|
||||
await service.list_history(site_id="site-1", start=debut, end=fin, limit=50, offset=10)
|
||||
|
||||
assert depot.appels == [("site-1", debut, fin, 50, 10)]
|
||||
|
||||
|
||||
async def test_list_history_defaults_to_the_last_24_hours_when_no_window_is_given() -> None:
|
||||
depot = FakeRepository([])
|
||||
service = ReadingService(readings=depot)
|
||||
avant = datetime.now(UTC)
|
||||
|
||||
await service.list_history(limit=500, offset=0)
|
||||
|
||||
apres = datetime.now(UTC)
|
||||
_, debut, fin, _, _ = depot.appels[0]
|
||||
assert avant <= fin <= apres
|
||||
assert fin - debut == FENETRE_PAR_DEFAUT
|
||||
|
||||
|
||||
async def test_list_history_defaults_end_to_now_when_only_start_is_given() -> None:
|
||||
depot = FakeRepository([])
|
||||
service = ReadingService(readings=depot)
|
||||
debut = datetime.now(UTC) - timedelta(hours=1)
|
||||
avant = datetime.now(UTC)
|
||||
|
||||
await service.list_history(start=debut, limit=500, offset=0)
|
||||
|
||||
apres = datetime.now(UTC)
|
||||
_, debut_transmis, fin, _, _ = depot.appels[0]
|
||||
assert debut_transmis == debut
|
||||
assert avant <= fin <= apres
|
||||
|
||||
|
||||
async def test_list_history_defaults_start_to_24_hours_before_end_when_only_end_is_given() -> None:
|
||||
depot = FakeRepository([])
|
||||
service = ReadingService(readings=depot)
|
||||
fin = datetime(2026, 9, 16, tzinfo=UTC)
|
||||
|
||||
await service.list_history(end=fin, limit=500, offset=0)
|
||||
|
||||
_, debut, fin_transmise, _, _ = depot.appels[0]
|
||||
assert fin_transmise == fin
|
||||
assert debut == fin - FENETRE_PAR_DEFAUT
|
||||
|
||||
|
||||
async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
|
||||
depot = FakeRepository([])
|
||||
service = ReadingService(readings=depot)
|
||||
|
||||
await service.list_history(
|
||||
start=datetime(2026, 9, 1), end=datetime(2026, 9, 2), limit=500, offset=0
|
||||
)
|
||||
|
||||
_, debut, fin, _, _ = depot.appels[0]
|
||||
assert debut == datetime(2026, 9, 1, tzinfo=UTC)
|
||||
assert fin == datetime(2026, 9, 2, tzinfo=UTC)
|
||||
|
||||
|
||||
async def test_list_history_raises_when_start_is_after_end() -> None:
|
||||
service = ReadingService(readings=FakeRepository([]))
|
||||
|
||||
with pytest.raises(FenetreInverseeError):
|
||||
await service.list_history(
|
||||
start=datetime(2026, 9, 2, tzinfo=UTC),
|
||||
end=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
limit=500,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
|
||||
async def test_list_history_raises_when_start_equals_end() -> None:
|
||||
service = ReadingService(readings=FakeRepository([]))
|
||||
instant = datetime(2026, 9, 1, tzinfo=UTC)
|
||||
|
||||
with pytest.raises(FenetreInverseeError):
|
||||
await service.list_history(start=instant, end=instant, limit=500, offset=0)
|
||||
|
||||
|
||||
async def test_list_history_raises_when_the_window_exceeds_the_maximum_span() -> None:
|
||||
service = ReadingService(readings=FakeRepository([]))
|
||||
debut = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
fin = debut + FENETRE_MAXIMALE + timedelta(seconds=1)
|
||||
|
||||
with pytest.raises(FenetreTropLargeError):
|
||||
await service.list_history(start=debut, end=fin, limit=500, offset=0)
|
||||
|
||||
|
||||
async def test_list_history_accepts_a_window_exactly_at_the_maximum_span() -> None:
|
||||
depot = FakeRepository([])
|
||||
service = ReadingService(readings=depot)
|
||||
debut = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
fin = debut + FENETRE_MAXIMALE
|
||||
|
||||
await service.list_history(start=debut, end=fin, limit=500, offset=0)
|
||||
|
||||
assert depot.appels == [(None, debut, fin, 500, 0)]
|
||||
Reference in New Issue
Block a user