Merge remote-tracking branch 'origin/dev' into feat/endpoint-sites-current
# Conflicts: # apps/backend/app/repositories/reading.py # apps/backend/openapi.json # docs/architecture/20-backend.md
This commit is contained in:
@@ -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)]
|
||||
@@ -0,0 +1,224 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.services.sensor import SensorService
|
||||
|
||||
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxSite:
|
||||
site_id: str
|
||||
site_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FauxLecture:
|
||||
site_id: str
|
||||
timestamp: datetime
|
||||
data_quality: str | None
|
||||
null_reasons: list[str] | None = field(default_factory=list)
|
||||
consumption_kw: float | None = 10.0
|
||||
voltage_v: float | None = 230.0
|
||||
current_a: float | None = 5.0
|
||||
power_factor: float | None = 0.95
|
||||
temperature_celsius: float | None = 21.0
|
||||
humidity_percent: float | None = 40.0
|
||||
|
||||
|
||||
class FauxDepotSites:
|
||||
def __init__(self, sites: list[FauxSite]) -> None:
|
||||
self._sites = sites
|
||||
|
||||
async def list_all(self) -> list[FauxSite]:
|
||||
return self._sites
|
||||
|
||||
|
||||
class FauxDepotLectures:
|
||||
def __init__(self, lectures: list[FauxLecture]) -> None:
|
||||
self._lectures = lectures
|
||||
|
||||
async def latest_by_site(self) -> list[FauxLecture]:
|
||||
return self._lectures
|
||||
|
||||
|
||||
async def test_status_marks_a_site_without_any_reading_as_critical_with_every_sensor_failing() -> (
|
||||
None
|
||||
):
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures([]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "critical"
|
||||
for capteur in (
|
||||
site.sensors.consumption,
|
||||
site.sensors.electrical,
|
||||
site.sensors.temperature,
|
||||
site.sensors.humidity,
|
||||
site.sensors.network,
|
||||
):
|
||||
assert capteur.status == "failing"
|
||||
assert capteur.since is None
|
||||
|
||||
|
||||
async def test_status_marks_every_sensor_ok_on_a_good_quality_reading_with_no_null_field() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "good")]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "ok"
|
||||
for capteur in (
|
||||
site.sensors.consumption,
|
||||
site.sensors.electrical,
|
||||
site.sensors.temperature,
|
||||
site.sensors.humidity,
|
||||
site.sensors.network,
|
||||
):
|
||||
assert capteur.status == "ok"
|
||||
assert capteur.since is None
|
||||
|
||||
|
||||
async def test_status_flags_the_sensor_named_in_null_reasons() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[
|
||||
FauxLecture(
|
||||
"A",
|
||||
TIMESTAMP,
|
||||
"partial",
|
||||
null_reasons=["temperature_sensor_failure"],
|
||||
temperature_celsius=None,
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "degraded"
|
||||
assert site.sensors.temperature.status == "failing"
|
||||
assert site.sensors.temperature.since == TIMESTAMP
|
||||
assert site.sensors.consumption.status == "ok"
|
||||
assert site.sensors.electrical.status == "ok"
|
||||
assert site.sensors.humidity.status == "ok"
|
||||
assert site.sensors.network.status == "ok"
|
||||
|
||||
|
||||
async def test_status_flags_a_sensor_from_a_null_field_even_without_a_null_reason() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], humidity_percent=None)]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.sensors.humidity.status == "failing"
|
||||
assert site.sensors.humidity.since == TIMESTAMP
|
||||
|
||||
|
||||
async def test_status_flags_electrical_as_failing_when_any_of_its_three_fields_is_null() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], power_factor=None)]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.sensors.electrical.status == "failing"
|
||||
|
||||
|
||||
async def test_status_forces_every_sensor_to_failing_when_overall_is_critical() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "critical", null_reasons=[])]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "critical"
|
||||
for capteur in (
|
||||
site.sensors.consumption,
|
||||
site.sensors.electrical,
|
||||
site.sensors.temperature,
|
||||
site.sensors.humidity,
|
||||
site.sensors.network,
|
||||
):
|
||||
assert capteur.status == "failing"
|
||||
assert capteur.since == TIMESTAMP
|
||||
|
||||
|
||||
async def test_status_treats_an_unknown_data_quality_as_critical() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, None, null_reasons=[])]), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
assert etat.sites[0].overall == "critical"
|
||||
|
||||
|
||||
async def test_status_ignores_an_unknown_null_reason() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[FauxLecture("A", TIMESTAMP, "good", null_reasons=["something_else"])]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "ok"
|
||||
for capteur in (
|
||||
site.sensors.consumption,
|
||||
site.sensors.electrical,
|
||||
site.sensors.temperature,
|
||||
site.sensors.humidity,
|
||||
site.sensors.network,
|
||||
):
|
||||
assert capteur.status == "ok"
|
||||
|
||||
|
||||
async def test_status_flags_network_from_null_reasons_only() -> None:
|
||||
service = SensorService(
|
||||
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
|
||||
readings=FauxDepotLectures( # type: ignore[arg-type]
|
||||
[
|
||||
FauxLecture(
|
||||
"A",
|
||||
TIMESTAMP,
|
||||
"partial",
|
||||
null_reasons=["network_loss"],
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
etat = await service.status()
|
||||
|
||||
site = etat.sites[0]
|
||||
assert site.overall == "degraded"
|
||||
assert site.sensors.network.status == "failing"
|
||||
assert site.sensors.network.since == TIMESTAMP
|
||||
assert site.sensors.consumption.status == "ok"
|
||||
assert site.sensors.electrical.status == "ok"
|
||||
assert site.sensors.temperature.status == "ok"
|
||||
assert site.sensors.humidity.status == "ok"
|
||||
Reference in New Issue
Block a user