test(backend): traverse l'API jusqu'a PostgreSQL sur les sites et les recommandations
Les tests d'API remplacaient tous leur service par un double : rien ne prouvait que `endpoint -> service -> repository -> SQL` rende ce que l'endpoint serialise. Seules l'authentification et la matrice de roles traversaient vraiment la base. `tests/api/conftest.py` seme un jeu metier valide en base et nettoie derriere lui. Il valide ses ecritures, contrairement aux fixtures de `tests/repositories` : un endpoint ouvre sa propre session et ne verrait pas une transaction en cours. L'isolation vient de la marque portee par chaque `site_id`, jamais d'un total : ces routes listent toute la base. Les recommandations d'abord, parce que `POST /generate` est la seule route d'ecriture : son idempotence tient a une contrainte d'unicite et a un `on_conflict_do_nothing`, invérifiables hors base, et sa relecture par une seconde requete HTTP est la seule assertion du depot qui prouve que la validation atteint le disque. Cote sites, le depart des ex aequo par `reading_id` quand deux sources ecrivent la meme heure ne peut se demontrer qu'ainsi.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"""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, 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(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)
|
||||
Reference in New Issue
Block a user