fix(backend): fiabilise le tri des lectures/predictions et la detection de redemarrage a zero
Backend / Tests exigeant une base (push) Failing after 38s
Backend / Lint, typage et tests (push) Successful in 1m29s
Backend / Audit des dépendances (push) Successful in 1m3s
SonarQube / build-back (push) Successful in 1m8s
SonarQube / build-front (push) Successful in 9m36s
SonarQube / test-back (push) Failing after 1m5s
SonarQube / test-front (push) Failing after 5m15s
SonarQube / SonarQube (push) Skipped

This commit is contained in:
Dorian
2026-09-18 16:58:03 +02:00
parent a9e124a97d
commit c059f838bb
8 changed files with 208 additions and 30 deletions
@@ -68,6 +68,30 @@ async def test_list_since_excludes_predictions_that_are_not_available(
assert list(resultats) == []
async def test_list_since_breaks_a_target_at_tie_by_ascending_prediction_id(
session: AsyncSession,
) -> None:
# `prediction` n'a pas d'unicité sur `(site_id, target_at)` : deux runs de scoring sans
# nouvelle lecture entre-temps produisent deux lignes `available` à la même cible. Sans ce
# départage, `_detect_anomaly` retiendrait une ligne au hasard plutôt que le run le plus
# récent.
site = await creer_site(session)
depot = PredictionRepository(session)
cible = datetime(2026, 9, 16, tzinfo=UTC)
premier_run = await creer_prediction(
session, site_id=site.site_id, target_at=cible, predicted_value=10.0
)
second_run = await creer_prediction(
session, site_id=site.site_id, target_at=cible, predicted_value=20.0
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [p.prediction_id for p in resultats]
await session.rollback()
assert identifiants == [premier_run.prediction_id, second_run.prediction_id]
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
@@ -189,6 +189,29 @@ async def test_list_since_excludes_readings_before_the_cutoff(session: AsyncSess
assert identifiants == [dedans.reading_id]
async def test_list_since_breaks_a_timestamp_tie_by_ascending_reading_id(
session: AsyncSession,
) -> None:
# `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la `source`
# diffère (même piège que `latest_for_site`). Sans ce départage, `_detect_spike` traiterait
# cette paire comme une variation réelle selon un ordre non garanti par le plan d'exécution.
site = await creer_site(session)
depot = ReadingRepository(session)
horodatage = datetime(2026, 9, 16, tzinfo=UTC)
premiere = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10
)
seconde = await creer_lecture(
session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42
)
resultats = await depot.list_since(since=datetime(2026, 9, 1, tzinfo=UTC), site_id=site.site_id)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [premiere.reading_id, seconde.reading_id]
async def test_list_since_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
+68 -1
View File
@@ -262,6 +262,28 @@ async def test_detect_ignores_a_prediction_whose_target_at_does_not_match_the_re
assert [a for a in depot.crees if a.type == "anomaly"] == []
async def test_detect_keeps_the_most_recent_run_when_two_predictions_share_the_same_target() -> (
None
):
# `PredictionRepository.list_since` départage les égalités de `target_at` par `prediction_id`
# croissant : le repository fait donc déjà passer le run le plus récent en dernier dans la
# liste, et c'est ce dernier que le dict de `_detect_anomaly` doit retenir.
svc, depot = service(
sites=[FauxSite("A")],
lectures=[FauxLecture("A", NOW, consumption_kwh=100.0)],
predictions=[
FauxPrediction("A", target_at=NOW, predicted_value=100.0, prediction_id=1),
FauxPrediction("A", target_at=NOW, predicted_value=70.0, prediction_id=2),
],
)
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "anomaly"]
assert candidate.threshold == 70.0
assert candidate.prediction_id == 2
async def test_detect_raises_an_outage_alert_past_the_threshold() -> None:
derniere = NOW - OUTAGE_THRESHOLD - timedelta(minutes=1)
svc, depot = service(
@@ -345,7 +367,35 @@ async def test_detect_returns_early_when_there_is_no_site() -> None:
assert depot.crees == []
async def test_detect_ignores_a_spike_when_the_previous_reading_is_zero() -> None:
async def test_detect_ignores_a_spike_pair_with_a_missing_measurement() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=None),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_ignores_a_reading_still_at_zero_after_a_previous_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW - timedelta(hours=1), consumption_kw=0.0),
FauxLecture("A", NOW, consumption_kw=0.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
async def test_detect_raises_a_critical_spike_when_a_site_restarts_from_zero() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
@@ -356,6 +406,23 @@ async def test_detect_ignores_a_spike_when_the_previous_reading_is_zero() -> Non
await svc.detect(now=NOW)
(candidate,) = [a for a in depot.crees if a.type == "spike"]
assert candidate.severity == "critical"
assert candidate.value == 50.0
assert candidate.threshold == 0.0
async def test_detect_ignores_a_spike_pair_sharing_the_same_timestamp() -> None:
svc, depot = service(
sites=[FauxSite("A")],
lectures=[
FauxLecture("A", NOW, consumption_kw=100.0),
FauxLecture("A", NOW, consumption_kw=160.0),
],
)
await svc.detect(now=NOW)
assert [a for a in depot.crees if a.type == "spike"] == []
+29 -7
View File
@@ -1,8 +1,10 @@
from datetime import UTC, datetime
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_session_factory
from app.detection import internal_alerts
from app.repositories.alert import AlertRepository
from tests.repositories.test_reading import creer_lecture
@@ -50,16 +52,36 @@ def test_main_prints_how_many_alerts_were_recorded(
@pytest.mark.integration
async def test_run_detection_writes_a_threshold_alert_end_to_end(session: AsyncSession) -> None:
# `run_detection` ouvre sa propre session et commite : `session.rollback()` seul ne défait
# rien ici (contrairement au reste de la suite), d'où le nettoyage explicite ci-dessous, sur
# le modèle de `tests/api/test_matrice_acces.py`.
site = await creer_site(session, capacity_kw=100.0)
site_id = site.site_id
instant = datetime(2026, 9, 16, 12, tzinfo=UTC)
await creer_lecture(session, site_id=site.site_id, timestamp=instant, consumption_kw=150.0)
await creer_lecture(session, site_id=site_id, timestamp=instant, consumption_kw=150.0)
await session.commit()
nombre = await internal_alerts.run_detection(now=instant, site_id=site.site_id)
try:
nombre = await internal_alerts.run_detection(now=instant, site_id=site_id)
alertes = await AlertRepository(session).list_all(site_id=site.site_id)
types = [a.type for a in alertes]
await session.rollback()
alertes = await AlertRepository(session).list_all(site_id=site_id)
types = [a.type for a in alertes]
await session.rollback()
assert nombre == 1
assert types == ["threshold"]
assert nombre == 1
assert types == ["threshold"]
finally:
# `site.site_id` n'est plus sûr après `session.rollback()` : le rollback expire tous les
# objets de la session (indépendamment d'`expire_on_commit`), et y accéder ici relance une
# requête hors contexte async. D'où `site_id`, capturé avant.
async with get_session_factory()() as nettoyage:
await nettoyage.execute(
text("delete from alert where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.execute(
text("delete from reading where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.execute(
text("delete from site where site_id = :site_id"), {"site_id": site_id}
)
await nettoyage.commit()