feat(backend): expose GET /api/v1/alerts
Consultation des alertes de consommation, filtrable par site_id et severity a l'identique du contrat GET /alerts de l'API Mock. Reprend le gabarit endpoints -> services -> repositories -> models pose par sites, sur la table alert deja creee par la revision Alembic e6d2026091501. Generalise aussi le garde-fou OpenAPI du 403 (ROUTES_A_ROLE) au-dela du seul tag users, pour que l'ajout d'alerts a la liste des routes protegees par role soit reellement verifie. Closes #59
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
from collections.abc import Callable, Iterator
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.api.deps import get_alert_service, get_current_principal
|
||||
from app.core.principal import Principal
|
||||
from app.core.roles import AccountKind, Role
|
||||
from app.models.energy import Alert
|
||||
|
||||
|
||||
def principal(role: Role = Role.LECTEUR) -> Principal:
|
||||
return Principal(
|
||||
id=uuid4(),
|
||||
email=f"{role.value}@enervision.fr",
|
||||
role=role,
|
||||
kind=AccountKind.HUMAIN,
|
||||
must_change_password=False,
|
||||
)
|
||||
|
||||
|
||||
def alert(alert_id: int = 1, site_id: str = "site-1", severity: str = "high") -> Alert:
|
||||
return Alert(
|
||||
alert_id=alert_id,
|
||||
source_alert_id=f"ALR-{alert_id}",
|
||||
site_id=site_id,
|
||||
source="enervision",
|
||||
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
|
||||
type="threshold",
|
||||
severity=severity,
|
||||
message="Dépassement du seuil configuré",
|
||||
value=812.5,
|
||||
threshold=720.0,
|
||||
metric="consumption_kw",
|
||||
prediction_id=None,
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
|
||||
class FauxService:
|
||||
def __init__(self) -> None:
|
||||
self.alert = alert()
|
||||
self.appels: list[tuple[str | None, str | None]] = []
|
||||
|
||||
async def list_all(
|
||||
self, *, site_id: str | None = None, severity: str | None = None
|
||||
) -> list[Alert]:
|
||||
self.appels.append((site_id, severity))
|
||||
return [self.alert]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lecteur_connecte(app: FastAPI) -> Iterator[None]:
|
||||
app.dependency_overrides[get_current_principal] = lambda: principal()
|
||||
yield
|
||||
app.dependency_overrides.pop(get_current_principal, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def servi(app: FastAPI, lecteur_connecte: None) -> Iterator[Callable[[], FauxService]]:
|
||||
def installe() -> FauxService:
|
||||
service = FauxService()
|
||||
app.dependency_overrides[get_alert_service] = lambda: service
|
||||
return service
|
||||
|
||||
yield installe
|
||||
app.dependency_overrides.pop(get_alert_service, None)
|
||||
|
||||
|
||||
async def test_list_alerts_returns_the_alerts(
|
||||
servi: Callable[[], FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/alerts")
|
||||
|
||||
assert response.status_code == 200
|
||||
corps = response.json()
|
||||
assert corps == [
|
||||
{
|
||||
"alert_id": 1,
|
||||
"site_id": "site-1",
|
||||
"timestamp": "2026-09-16T00:00:00Z",
|
||||
"type": "threshold",
|
||||
"severity": "high",
|
||||
"message": "Dépassement du seuil configuré",
|
||||
"value": 812.5,
|
||||
"threshold": 720.0,
|
||||
"metric": "consumption_kw",
|
||||
"prediction_id": None,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_list_alerts_transmits_the_site_id_filter(
|
||||
servi: Callable[[], FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
service = servi()
|
||||
|
||||
await client.get("/api/v1/alerts?site_id=site-1")
|
||||
|
||||
assert service.appels == [("site-1", None)]
|
||||
|
||||
|
||||
async def test_list_alerts_transmits_the_severity_filter(
|
||||
servi: Callable[[], FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
service = servi()
|
||||
|
||||
await client.get("/api/v1/alerts?severity=critical")
|
||||
|
||||
assert service.appels == [(None, "critical")]
|
||||
|
||||
|
||||
async def test_list_alerts_returns_422_for_an_unknown_severity(
|
||||
servi: Callable[[], FauxService], client: AsyncClient
|
||||
) -> None:
|
||||
servi()
|
||||
|
||||
response = await client.get("/api/v1/alerts?severity=invalide")
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
async def test_list_alerts_returns_an_empty_list_when_there_is_nothing(
|
||||
lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient
|
||||
) -> None:
|
||||
fake_session(result=[])
|
||||
|
||||
response = await client.get("/api/v1/alerts")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
@@ -22,6 +22,18 @@ ORIGINE_VERIFIEE = {
|
||||
("POST", "/api/v1/auth/password"),
|
||||
}
|
||||
|
||||
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
|
||||
# `password_change_required`, pas seulement les routes `admin`.
|
||||
ROUTES_A_ROLE = {
|
||||
("GET", "/api/v1/users"),
|
||||
("POST", "/api/v1/users"),
|
||||
("PATCH", "/api/v1/users/{id}"),
|
||||
("POST", "/api/v1/users/{id}/password-reset"),
|
||||
("GET", "/api/v1/sites"),
|
||||
("GET", "/api/v1/sites/{site_id}"),
|
||||
("GET", "/api/v1/alerts"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def schema() -> dict[str, Any]:
|
||||
@@ -55,11 +67,11 @@ def test_every_route_demanding_an_identity_says_how_it_refuses(schema: dict[str,
|
||||
assert muettes == []
|
||||
|
||||
|
||||
def test_every_administration_route_documents_the_role_refusal(schema: dict[str, Any]) -> None:
|
||||
def test_every_role_guarded_route_documents_the_role_refusal(schema: dict[str, Any]) -> None:
|
||||
sans_403 = [
|
||||
(methode, chemin)
|
||||
for methode, chemin, operation in operations(schema)
|
||||
if "users" in operation.get("tags", []) and "403" not in operation["responses"]
|
||||
if (methode, chemin) in ROUTES_A_ROLE and "403" not in operation["responses"]
|
||||
]
|
||||
|
||||
assert sans_403 == []
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.energy import Alert
|
||||
from app.repositories.alert import AlertRepository
|
||||
from tests.repositories.test_site import creer as creer_site
|
||||
from tests.repositories.test_site import identifiant as identifiant_site
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def creer_alerte(session: AsyncSession, *, site_id: str, **overrides: object) -> Alert:
|
||||
alerte = Alert(
|
||||
source_alert_id=overrides.get("source_alert_id", f"ALR-{uuid.uuid4().hex[:12]}"),
|
||||
site_id=site_id,
|
||||
source=overrides.get("source", "enervision"),
|
||||
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
|
||||
type=overrides.get("type", "threshold"),
|
||||
severity=overrides.get("severity", "high"),
|
||||
message=overrides.get("message", "Dépassement du seuil configuré"),
|
||||
value=overrides.get("value", 812.5),
|
||||
threshold=overrides.get("threshold", 720.0),
|
||||
metric=overrides.get("metric", "consumption_kw"),
|
||||
prediction_id=overrides.get("prediction_id"),
|
||||
raw_data=overrides.get("raw_data", {}),
|
||||
)
|
||||
session.add(alerte)
|
||||
await session.flush()
|
||||
return alerte
|
||||
|
||||
|
||||
async def test_list_all_returns_the_alerts_sorted_by_timestamp_descending(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = AlertRepository(session)
|
||||
ancienne = await creer_alerte(
|
||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)
|
||||
)
|
||||
recente = await creer_alerte(
|
||||
session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC)
|
||||
)
|
||||
|
||||
alertes = await depot.list_all()
|
||||
identifiants = [
|
||||
a.alert_id for a in alertes if a.alert_id in (ancienne.alert_id, recente.alert_id)
|
||||
]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [recente.alert_id, ancienne.alert_id]
|
||||
|
||||
|
||||
async def test_list_all_filters_by_site_id(session: AsyncSession) -> None:
|
||||
premier = await creer_site(session)
|
||||
second = await creer_site(session)
|
||||
depot = AlertRepository(session)
|
||||
voulue = await creer_alerte(session, site_id=premier.site_id)
|
||||
await creer_alerte(session, site_id=second.site_id)
|
||||
|
||||
alertes = await depot.list_all(site_id=premier.site_id)
|
||||
identifiants = [a.alert_id for a in alertes]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [voulue.alert_id]
|
||||
|
||||
|
||||
async def test_list_all_filters_by_severity(session: AsyncSession) -> None:
|
||||
site = await creer_site(session)
|
||||
depot = AlertRepository(session)
|
||||
voulue = await creer_alerte(session, site_id=site.site_id, severity="critical")
|
||||
await creer_alerte(session, site_id=site.site_id, severity="low")
|
||||
|
||||
alertes = await depot.list_all(severity="critical")
|
||||
identifiants = [a.alert_id for a in alertes]
|
||||
await session.rollback()
|
||||
|
||||
assert identifiants == [voulue.alert_id]
|
||||
|
||||
|
||||
async def test_list_all_returns_an_empty_list_when_there_is_nothing(
|
||||
session: AsyncSession,
|
||||
) -> None:
|
||||
depot = AlertRepository(session)
|
||||
|
||||
alertes = await depot.list_all(site_id=identifiant_site())
|
||||
|
||||
assert list(alertes) == []
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.models.energy import Alert
|
||||
from app.services.alert import AlertService
|
||||
|
||||
|
||||
def alert(
|
||||
alert_id: int = 1,
|
||||
site_id: str = "site-1",
|
||||
severity: str = "high",
|
||||
) -> Alert:
|
||||
return Alert(
|
||||
alert_id=alert_id,
|
||||
source_alert_id=f"ALR-{alert_id}",
|
||||
site_id=site_id,
|
||||
source="enervision",
|
||||
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
|
||||
type="threshold",
|
||||
severity=severity,
|
||||
message="Dépassement du seuil configuré",
|
||||
value=812.5,
|
||||
threshold=720.0,
|
||||
metric="consumption_kw",
|
||||
prediction_id=None,
|
||||
raw_data={},
|
||||
)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self, alerts: list[Alert]) -> None:
|
||||
self._alerts = alerts
|
||||
self.appels: list[tuple[str | None, str | None]] = []
|
||||
|
||||
async def list_all(
|
||||
self, *, site_id: str | None = None, severity: str | None = None
|
||||
) -> list[Alert]:
|
||||
self.appels.append((site_id, severity))
|
||||
return self._alerts
|
||||
|
||||
|
||||
async def test_list_all_returns_the_repository_alerts() -> None:
|
||||
service = AlertService(alerts=FakeRepository([alert(1), alert(2)]))
|
||||
|
||||
alertes = await service.list_all()
|
||||
|
||||
assert [a.alert_id for a in alertes] == [1, 2]
|
||||
|
||||
|
||||
async def test_list_all_relays_the_filters_to_the_repository() -> None:
|
||||
depot = FakeRepository([])
|
||||
service = AlertService(alerts=depot)
|
||||
|
||||
await service.list_all(site_id="site-1", severity="critical")
|
||||
|
||||
assert depot.appels == [("site-1", "critical")]
|
||||
Reference in New Issue
Block a user