Airflow / Construction de l'image (push) Successful in 1m2s
Backend / Analyse statique de sécurité (push) Successful in 7s
Backend / Tests exigeant une base (push) Failing after 5m3s
Airflow / Lint et intégrité des DAGs (push) Successful in 9m41s
ML / Analyse statique de sécurité (push) Successful in 6s
Backend / Lint, typage et tests (push) Successful in 10m10s
Backend / Audit des dépendances (push) Successful in 9m36s
ML / ML - DB et chaîne ML - DB - API (push) Failing after 5m6s
SonarQube / test-ml (push) Failing after 6m6s
ML / Lint, typage et tests (push) Successful in 11m31s
SonarQube / build-front (push) Successful in 9m49s
SonarQube / build-back (push) Successful in 9m54s
SonarQube / test-front (push) Failing after 5m10s
SonarQube / test-back (push) Failing after 5m13s
SonarQube / SonarQube (push) Skipped
Revue de la branche : trois defauts empechaient la surveillance de tenir ce qu'elle annonce. - `evaluate()` gardait les microsecondes de `now()` dans `window_end`, la cle de `uq_drift_report_window`. Deux executions ne collidaient donc jamais et l'index ne dedoublonnait rien, contrairement a ce qu'affirmaient l'ADR 0011, 20-backend et le docstring du DAG. L'instant de reference est desormais tronque a l'heure. - Un site qui cessait d'etre score disparaissait du rapport : la liste des sites ne venait que de la fenetre recente. La panne que cette surveillance existe pour dire etait exactement celle qu'elle taisait. La fenetre de reference entre maintenant dans l'union, et le site recoit sa ligne `indetermine` a zero observation. - Sans fenetre de reference, `_plafond` rendait `None` et le verdict tombait sur `stable`, une affirmation que la donnee ne portait pas. C'est `indetermine` desormais. `ml.yml` ecoute `apps/backend/app/**` et non les seuls modeles : ce workflow est le seul a jouer `-m chaine`, or la chaine traverse les endpoints, les services et les schemas jusqu'a `GET /predictions`. Une PR touchant `predictions.py` ne declenchait pas le test qui l'assert. Hygiene de tests : le nettoyage des fixtures API connait `drift_report` (cle etrangere RESTRICT vers `site`), le test sans rapport rend ses overrides en teardown, `test_chaine_ml_api` compare les `created_at` strictement (un `>=` passait aussi quand l'API resservait la premiere ligne), et `test_data_integration` filtre sur le site seme au lieu de juger tout le contenu d'une fenetre dans une base partagee. Docs remises d'aplomb : sept revisions Alembic et non six, `derive.py` dans l'inventaire de etl/README, et le diagramme de 20-backend gagne DriftService, le depot drift et sa treizieme table.
133 lines
5.1 KiB
Python
133 lines
5.1 KiB
Python
"""Piège : ces fixtures valident leurs écritures, contrairement à celles de tests/repositories.
|
|
|
|
Un endpoint ouvre sa propre session par `get_session` : il ne verrait pas une ligne semée dans
|
|
une transaction en cours. Lui passer la session de la fixture par `dependency_overrides`
|
|
supprimerait justement ce que ces tests prouvent, et `RecommendationService.generate` valide de
|
|
toute façon lui-même. L'isolation vient donc de la marque portée par chaque `site_id`, et le
|
|
nettoyage est explicite, dans l'ordre imposé par les clés étrangères `RESTRICT`.
|
|
|
|
Contrainte : toutes ces fixtures sont à portée fonction. `engine_per_test` vide le cache du
|
|
moteur après chaque test ; une fixture de module verrait un moteur déjà fermé à son démontage,
|
|
et ses lignes resteraient en base.
|
|
"""
|
|
|
|
from collections.abc import AsyncIterator, Callable, Iterator
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime, timedelta
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_current_principal
|
|
from app.core.principal import Principal
|
|
from app.core.roles import AccountKind, Role
|
|
from app.db.session import get_session_factory
|
|
from app.models.energy import Alert, DriftReport, Prediction, Reading, Recommendation, Site
|
|
from tests.repositories.test_alert import creer_alerte
|
|
from tests.repositories.test_prediction import creer_prediction
|
|
from tests.repositories.test_reading import creer_lecture
|
|
from tests.repositories.test_site import creer as creer_site
|
|
|
|
INSTANT = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class JeuMetier:
|
|
"""Identifiants seuls, jamais d'instance ORM : un attribut relu sur une session fermée
|
|
déclenche un `MissingGreenlet`."""
|
|
|
|
site_id: str
|
|
site_voisin: str
|
|
alert_id: int
|
|
prediction_id: int
|
|
instant: datetime
|
|
|
|
|
|
async def _supprime(session: AsyncSession, sites: list[str]) -> None:
|
|
# La suppression des recommandations est inconditionnelle : `POST /generate` en cree hors du
|
|
# controle de la fixture, et `alert` les retient par une cle etrangere `RESTRICT`.
|
|
alertes = select(Alert.alert_id).where(Alert.site_id.in_(sites))
|
|
await session.execute(delete(Recommendation).where(Recommendation.alert_id.in_(alertes)))
|
|
await session.execute(delete(Alert).where(Alert.site_id.in_(sites)))
|
|
await session.execute(delete(DriftReport).where(DriftReport.site_id.in_(sites)))
|
|
await session.execute(delete(Prediction).where(Prediction.site_id.in_(sites)))
|
|
await session.execute(delete(Reading).where(Reading.site_id.in_(sites)))
|
|
await session.execute(delete(Site).where(Site.site_id.in_(sites)))
|
|
await session.commit()
|
|
|
|
|
|
@pytest.fixture
|
|
def marque() -> str:
|
|
return uuid4().hex[:12]
|
|
|
|
|
|
@pytest.fixture
|
|
async def jeu_metier(marque: str) -> AsyncIterator[JeuMetier]:
|
|
"""Un site instrumenté, un site voisin, trois lectures horaires, une prédiction, une alerte.
|
|
|
|
Le voisin existe pour que les tests de filtre prouvent qu'ils écartent quelque chose.
|
|
"""
|
|
site_id = f"SITE-{marque}"
|
|
voisin = f"SITE-{marque}-VOISIN"
|
|
|
|
async with get_session_factory()() as session:
|
|
await creer_site(session, site_id=site_id, capacity_kw=100.0)
|
|
await creer_site(session, site_id=voisin, capacity_kw=100.0)
|
|
for decalage in range(3):
|
|
await creer_lecture(
|
|
session,
|
|
site_id=site_id,
|
|
timestamp=INSTANT - timedelta(hours=decalage),
|
|
consumption_kw=10.0 + decalage,
|
|
)
|
|
prediction = await creer_prediction(session, site_id=site_id, target_at=INSTANT)
|
|
alerte = await creer_alerte(session, site_id=site_id, timestamp=INSTANT)
|
|
jeu = JeuMetier(
|
|
site_id=site_id,
|
|
site_voisin=voisin,
|
|
alert_id=alerte.alert_id,
|
|
prediction_id=prediction.prediction_id,
|
|
instant=INSTANT,
|
|
)
|
|
await session.commit()
|
|
|
|
try:
|
|
yield jeu
|
|
finally:
|
|
async with get_session_factory()() as session:
|
|
await _supprime(session, [site_id, voisin])
|
|
|
|
|
|
@pytest.fixture
|
|
async def site_nu(marque: str) -> AsyncIterator[str]:
|
|
"""Un site sans lecture ni prédiction : le cas que seul un vrai `LEFT JOIN` distingue."""
|
|
site_id = f"SITE-{marque}-NU"
|
|
|
|
async with get_session_factory()() as session:
|
|
await creer_site(session, site_id=site_id, capacity_kw=100.0)
|
|
await session.commit()
|
|
|
|
try:
|
|
yield site_id
|
|
finally:
|
|
async with get_session_factory()() as session:
|
|
await _supprime(session, [site_id])
|
|
|
|
|
|
@pytest.fixture
|
|
def principal_injecte(app: FastAPI) -> Iterator[Callable[[Role], None]]:
|
|
def installe(role: Role = Role.LECTEUR) -> None:
|
|
app.dependency_overrides[get_current_principal] = lambda: Principal(
|
|
id=uuid4(),
|
|
email="parcours@enervision.fr",
|
|
role=role,
|
|
kind=AccountKind.HUMAIN,
|
|
must_change_password=False,
|
|
)
|
|
|
|
yield installe
|
|
app.dependency_overrides.pop(get_current_principal, None)
|