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)
|
||||
@@ -0,0 +1,92 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core.roles import Role
|
||||
from tests.api.conftest import JeuMetier
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def genere(client: AsyncClient, site_id: str) -> dict[str, int]:
|
||||
# Toujours borne a un site : sans `site_id`, le service examine toutes les alertes de la
|
||||
# base, y compris celles d'un autre test, et le rapport cesse d'etre deterministe.
|
||||
reponse = await client.post(f"/api/v1/recommendations/generate?site_id={site_id}")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
return dict(reponse.json())
|
||||
|
||||
|
||||
async def test_generate_creates_a_recommendation_for_the_alert_of_the_requested_site(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
|
||||
rapport = await genere(client, jeu_metier.site_id)
|
||||
|
||||
assert rapport["alerts_examined"] == 1
|
||||
assert rapport["recommendations_created"] >= 1
|
||||
assert rapport["already_present"] == 0
|
||||
|
||||
|
||||
async def test_generate_creates_nothing_more_when_it_runs_twice_on_the_same_alerts(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
premier = await genere(client, jeu_metier.site_id)
|
||||
|
||||
second = await genere(client, jeu_metier.site_id)
|
||||
|
||||
assert second["recommendations_created"] == 0
|
||||
assert second["already_present"] == premier["recommendations_created"]
|
||||
|
||||
|
||||
async def test_generate_examines_no_alert_when_the_requested_site_has_none(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
|
||||
rapport = await genere(client, jeu_metier.site_voisin)
|
||||
|
||||
assert rapport["alerts_examined"] == 0
|
||||
assert rapport["recommendations_created"] == 0
|
||||
|
||||
|
||||
async def test_list_recommendations_returns_what_generate_persisted_in_another_session(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
await genere(client, jeu_metier.site_id)
|
||||
|
||||
reponse = await client.get("/api/v1/recommendations")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
miennes = [r for r in reponse.json() if r["alert_id"] == jeu_metier.alert_id]
|
||||
assert miennes != []
|
||||
assert all(r["rule_reference"] for r in miennes)
|
||||
|
||||
|
||||
async def test_get_recommendation_returns_the_row_created_by_generate(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.ADMIN)
|
||||
await genere(client, jeu_metier.site_id)
|
||||
liste = await client.get("/api/v1/recommendations")
|
||||
creee = next(r for r in liste.json() if r["alert_id"] == jeu_metier.alert_id)
|
||||
|
||||
reponse = await client.get(f"/api/v1/recommendations/{creee['recommendation_id']}")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
assert reponse.json() == creee
|
||||
|
||||
|
||||
async def test_get_recommendation_returns_404_when_the_identifier_is_unknown(
|
||||
principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get("/api/v1/recommendations/9999999")
|
||||
|
||||
assert reponse.status_code == 404
|
||||
assert reponse.json()["detail"] == "Recommandation introuvable"
|
||||
@@ -0,0 +1,80 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core.roles import Role
|
||||
from app.db.session import get_session_factory
|
||||
from tests.api.conftest import JeuMetier
|
||||
from tests.repositories.test_reading import creer_lecture
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_list_sites_returns_the_seeded_site_with_its_stored_attributes(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get("/api/v1/sites")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
mien = next(site for site in reponse.json() if site["site_id"] == jeu_metier.site_id)
|
||||
assert mien["capacity_kw"] == 100.0
|
||||
assert mien["site_name"] == "Site de test"
|
||||
|
||||
|
||||
async def test_get_site_returns_404_when_the_identifier_is_absent_from_the_database(
|
||||
principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get("/api/v1/sites/SITE-JAMAIS-INSERE")
|
||||
|
||||
assert reponse.status_code == 404
|
||||
assert reponse.json()["detail"] == "Site introuvable"
|
||||
|
||||
|
||||
async def test_get_current_returns_the_most_recent_reading_when_several_hours_are_stored(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
corps = reponse.json()
|
||||
assert corps["consumption_kw"] == 10.0
|
||||
assert corps["timestamp"].startswith("2026-09-16T12:00")
|
||||
|
||||
|
||||
async def test_get_current_keeps_the_highest_reading_id_when_two_sources_share_the_timestamp(
|
||||
jeu_metier: JeuMetier, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
async with get_session_factory()() as session:
|
||||
await creer_lecture(
|
||||
session,
|
||||
site_id=jeu_metier.site_id,
|
||||
timestamp=jeu_metier.instant,
|
||||
source="api_history",
|
||||
consumption_kw=999.0,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
reponse = await client.get(f"/api/v1/sites/{jeu_metier.site_id}/current")
|
||||
|
||||
assert reponse.json()["consumption_kw"] == 999.0
|
||||
|
||||
|
||||
async def test_get_current_reports_a_critical_quality_when_the_site_has_no_reading(
|
||||
site_nu: str, principal_injecte: Callable[[Role], None], client: AsyncClient
|
||||
) -> None:
|
||||
principal_injecte(Role.LECTEUR)
|
||||
|
||||
reponse = await client.get(f"/api/v1/sites/{site_nu}/current")
|
||||
|
||||
assert reponse.status_code == 200
|
||||
corps = reponse.json()
|
||||
assert corps["timestamp"] is None
|
||||
assert corps["data_quality"] == "critical"
|
||||
Reference in New Issue
Block a user