diff --git a/apps/backend/tests/api/conftest.py b/apps/backend/tests/api/conftest.py new file mode 100644 index 0000000..12ca9f9 --- /dev/null +++ b/apps/backend/tests/api/conftest.py @@ -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) diff --git a/apps/backend/tests/api/test_parcours_recommandations.py b/apps/backend/tests/api/test_parcours_recommandations.py new file mode 100644 index 0000000..0e3e4e8 --- /dev/null +++ b/apps/backend/tests/api/test_parcours_recommandations.py @@ -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" diff --git a/apps/backend/tests/api/test_parcours_sites.py b/apps/backend/tests/api/test_parcours_sites.py new file mode 100644 index 0000000..7245fb4 --- /dev/null +++ b/apps/backend/tests/api/test_parcours_sites.py @@ -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"