From 07ea8d21dc6ef7a01b141369da8d4a74b10c1e26 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:25:14 +0200 Subject: [PATCH 01/25] feat(backend): expose GET /api/v1/sites/{site_id}/current pour l'issue #29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute la dernière mesure d'un site (SiteService.current), en réutilisant la vérification d'existence déjà en place pour GET /sites/{site_id} : SiteService gagne une dépendance ReadingRepository, sur le modèle de composition déjà utilisé par StatsService/SensorService. Un site connu sans lecture rend 200 avec les champs de mesure à null et data_quality="critical" ; seul un site_id absent rend 404. --- apps/backend/app/api/deps.py | 2 +- apps/backend/app/api/v1/endpoints/sites.py | 20 +- apps/backend/app/repositories/reading.py | 9 + apps/backend/app/schemas/site.py | 20 ++ apps/backend/app/services/site.py | 65 +++++- apps/backend/openapi.json | 221 +++++++++++++++++++++ apps/backend/tests/api/test_openapi.py | 1 + apps/backend/tests/api/test_sites.py | 52 ++++- apps/backend/tests/services/test_site.py | 89 ++++++++- docs/architecture/20-backend.md | 7 +- 10 files changed, 474 insertions(+), 12 deletions(-) diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index aaf7403..eb78758 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -140,7 +140,7 @@ UserServiceDep = Annotated[UserService, Depends(get_user_service)] def get_site_service(session: SessionDep) -> SiteService: - return SiteService(sites=SiteRepository(session)) + return SiteService(sites=SiteRepository(session), readings=ReadingRepository(session)) SiteServiceDep = Annotated[SiteService, Depends(get_site_service)] diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 984dd8b..93923e9 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, status from app.api.deps import LecteurDep, SiteServiceDep from app.api.openapi import REPONSE_VALIDATION, Reponses from app.schemas.errors import ErrorResponse -from app.schemas.site import SiteResponse +from app.schemas.site import SiteCurrentResponse, SiteResponse from app.services.site import SiteNotFoundError router = APIRouter() @@ -34,3 +34,21 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" ) from erreur return SiteResponse.model_validate(site) + + +@router.get( + "/{site_id}/current", + response_model=SiteCurrentResponse, + summary="Dernière mesure d'un site", + responses=REPONSES_INTROUVABLE, +) +async def get_current( + site_id: str, _: LecteurDep, service: SiteServiceDep +) -> SiteCurrentResponse: + try: + actuel = await service.current(site_id) + except SiteNotFoundError as erreur: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Site introuvable" + ) from erreur + return SiteCurrentResponse.model_validate(actuel) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index 5424b46..c05ae60 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -19,3 +19,12 @@ class ReadingRepository: .order_by(Reading.site_id, Reading.timestamp.desc()) ) return (await self._session.execute(requete)).scalars().all() + + async def latest_for_site(self, site_id: str) -> Reading | None: + requete = ( + select(Reading) + .where(Reading.site_id == site_id) + .order_by(Reading.timestamp.desc()) + .limit(1) + ) + return await self._session.scalar(requete) diff --git a/apps/backend/app/schemas/site.py b/apps/backend/app/schemas/site.py index 82035f5..56a61b7 100644 --- a/apps/backend/app/schemas/site.py +++ b/apps/backend/app/schemas/site.py @@ -1,3 +1,6 @@ +from datetime import datetime +from typing import Literal + from pydantic import BaseModel, ConfigDict @@ -10,3 +13,20 @@ class SiteResponse(BaseModel): location: str | None capacity_kw: float | None status: str | None + + +class SiteCurrentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: Literal["good", "partial", "degraded", "critical"] diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 515497a..25a819d 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -1,8 +1,16 @@ from collections.abc import Sequence +from dataclasses import dataclass +from datetime import datetime +from typing import Literal from app.models.energy import Site +from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) + class SiteError(Exception): pass @@ -12,9 +20,26 @@ class SiteNotFoundError(SiteError): pass +@dataclass(frozen=True, slots=True) +class SiteCurrentReading: + timestamp: datetime | None + site_id: str + site_type: str + consumption_kw: float | None + consumption_kwh: float | None + voltage_v: float | None + current_a: float | None + power_factor: float | None + temperature_celsius: float | None + humidity_percent: float | None + null_reasons: list[str] + data_quality: DataQuality + + class SiteService: - def __init__(self, *, sites: SiteRepository) -> None: + def __init__(self, *, sites: SiteRepository, readings: ReadingRepository) -> None: self._sites = sites + self._readings = readings async def list_all(self) -> Sequence[Site]: return await self._sites.list_all() @@ -24,3 +49,41 @@ class SiteService: if site is None: raise SiteNotFoundError(site_id) return site + + async def current(self, site_id: str) -> SiteCurrentReading: + site = await self.get_by_id(site_id) + derniere = await self._readings.latest_for_site(site_id) + + if derniere is None: + return SiteCurrentReading( + timestamp=None, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=None, + consumption_kwh=None, + voltage_v=None, + current_a=None, + power_factor=None, + temperature_celsius=None, + humidity_percent=None, + null_reasons=[], + data_quality="critical", + ) + + qualite: DataQuality = ( + derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + ) + return SiteCurrentReading( + timestamp=derniere.timestamp, + site_id=site.site_id, + site_type=site.site_type, + consumption_kw=derniere.consumption_kw, + consumption_kwh=derniere.consumption_kwh, + voltage_v=derniere.voltage_v, + current_a=derniere.current_a, + power_factor=derniere.power_factor, + temperature_celsius=derniere.temperature_celsius, + humidity_percent=derniere.humidity_percent, + null_reasons=derniere.null_reasons or [], + data_quality=qualite, + ) diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index af962df..6844f72 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -921,6 +921,93 @@ } } }, + "/api/v1/sites/{site_id}/current": { + "get": { + "tags": [ + "sites" + ], + "summary": "Dernière mesure d'un site", + "operationId": "get_current_api_v1_sites__site_id__current_get", + "security": [ + { + "Jeton d'accès": [] + } + ], + "parameters": [ + { + "name": "site_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Site Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SiteCurrentResponse" + } + } + } + }, + "500": { + "description": "Erreur interne. `correlation` identifie la trace côté serveur, qui n'est pas renvoyée au client.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalErrorResponse" + } + } + } + }, + "401": { + "description": "Jeton absent, illisible, périmé, ou rendu caduc par un changement de rôle ou une désactivation. L'en-tête `WWW-Authenticate` porte la cause dans `error=`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "Mot de passe provisoire à changer (`detail` vaut `password_change_required`).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "422": { + "description": "Corps invalide. Le détail nomme le champ fautif et le type d'erreur, jamais la valeur envoyée.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationErrorResponse" + } + } + } + }, + "404": { + "description": "Aucun site ne porte cet identifiant.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/alerts": { "get": { "tags": [ @@ -1572,6 +1659,140 @@ ], "title": "Role" }, + "SiteCurrentResponse": { + "properties": { + "timestamp": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Timestamp" + }, + "site_id": { + "type": "string", + "title": "Site Id" + }, + "site_type": { + "type": "string", + "title": "Site Type" + }, + "consumption_kw": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kw" + }, + "consumption_kwh": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Consumption Kwh" + }, + "voltage_v": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Voltage V" + }, + "current_a": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Current A" + }, + "power_factor": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Power Factor" + }, + "temperature_celsius": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Temperature Celsius" + }, + "humidity_percent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Humidity Percent" + }, + "null_reasons": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Null Reasons" + }, + "data_quality": { + "type": "string", + "enum": [ + "good", + "partial", + "degraded", + "critical" + ], + "title": "Data Quality" + } + }, + "type": "object", + "required": [ + "timestamp", + "site_id", + "site_type", + "consumption_kw", + "consumption_kwh", + "voltage_v", + "current_a", + "power_factor", + "temperature_celsius", + "humidity_percent", + "null_reasons", + "data_quality" + ], + "title": "SiteCurrentResponse" + }, "SiteResponse": { "properties": { "site_id": { diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index f7147da..3112937 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -31,6 +31,7 @@ ROUTES_A_ROLE = { ("POST", "/api/v1/users/{id}/password-reset"), ("GET", "/api/v1/sites"), ("GET", "/api/v1/sites/{site_id}"), + ("GET", "/api/v1/sites/{site_id}/current"), ("GET", "/api/v1/alerts"), ("GET", "/api/v1/recommendations"), ("GET", "/api/v1/recommendations/{recommendation_id}"), diff --git a/apps/backend/tests/api/test_sites.py b/apps/backend/tests/api/test_sites.py index 3692565..dea8850 100644 --- a/apps/backend/tests/api/test_sites.py +++ b/apps/backend/tests/api/test_sites.py @@ -1,4 +1,5 @@ from collections.abc import Callable, Iterator +from datetime import UTC, datetime from uuid import uuid4 import pytest @@ -9,7 +10,9 @@ from app.api.deps import get_current_principal, get_site_service from app.core.principal import Principal from app.core.roles import AccountKind, Role from app.models.energy import Site -from app.services.site import SiteNotFoundError +from app.services.site import SiteCurrentReading, SiteNotFoundError + +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) def principal(role: Role = Role.LECTEUR) -> Principal: @@ -33,10 +36,28 @@ def site(site_id: str = "site-1") -> Site: ) +def lecture_actuelle(site_id: str = "site-1") -> SiteCurrentReading: + return SiteCurrentReading( + timestamp=TIMESTAMP, + site_id=site_id, + site_type="industriel", + consumption_kw=87.34, + consumption_kwh=87.34, + voltage_v=401.2, + current_a=132.5, + power_factor=0.923, + temperature_celsius=22.1, + humidity_percent=58.4, + null_reasons=[], + data_quality="good", + ) + + class FauxService: def __init__(self, erreur: Exception | None = None) -> None: self._erreur = erreur self.site = site() + self.actuel = lecture_actuelle() async def list_all(self) -> list[Site]: return [self.site] @@ -46,6 +67,11 @@ class FauxService: raise self._erreur return self.site + async def current(self, site_id: str) -> SiteCurrentReading: + if self._erreur is not None: + raise self._erreur + return self.actuel + @pytest.fixture def lecteur_connecte(app: FastAPI) -> Iterator[None]: @@ -109,6 +135,30 @@ async def test_get_site_returns_404_for_an_unknown_site( assert response.status_code == 404 +async def test_get_current_returns_the_latest_reading( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi() + + response = await client.get("/api/v1/sites/site-1/current") + + assert response.status_code == 200 + corps = response.json() + assert corps["site_id"] == "site-1" + assert corps["data_quality"] == "good" + assert corps["consumption_kw"] == 87.34 + + +async def test_get_current_returns_404_for_an_unknown_site( + servi: Callable[..., FauxService], client: AsyncClient +) -> None: + servi(SiteNotFoundError("site-inconnu")) + + response = await client.get("/api/v1/sites/site-inconnu/current") + + assert response.status_code == 404 + + async def test_list_sites_reaches_the_repository_through_the_session( lecteur_connecte: None, fake_session: Callable[..., None], client: AsyncClient ) -> None: diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 73ef21f..7e555e8 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -1,8 +1,13 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime + import pytest from app.models.energy import Site from app.services.site import SiteNotFoundError, SiteService +TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC) + def site(site_id: str = "site-1") -> Site: return Site( @@ -15,6 +20,21 @@ def site(site_id: str = "site-1") -> Site: ) +@dataclass +class FauxLecture: + site_id: str + timestamp: datetime = TIMESTAMP + consumption_kw: float | None = 87.34 + consumption_kwh: float | None = 87.34 + voltage_v: float | None = 401.2 + current_a: float | None = 132.5 + power_factor: float | None = 0.923 + temperature_celsius: float | None = 22.1 + humidity_percent: float | None = 58.4 + null_reasons: list[str] | None = field(default_factory=list) + data_quality: str | None = "good" + + class FakeRepository: def __init__(self, sites: list[Site]) -> None: self._sites = sites @@ -26,24 +46,79 @@ class FakeRepository: return next((s for s in self._sites if s.site_id == site_id), None) -async def test_list_all_returns_the_repository_sites() -> None: - service = SiteService(sites=FakeRepository([site("a"), site("b")])) +class FauxDepotLectures: + def __init__(self, lectures: dict[str, FauxLecture]) -> None: + self._lectures = lectures - sites = await service.list_all() + async def latest_for_site(self, site_id: str) -> FauxLecture | None: + return self._lectures.get(site_id) + + +def service( + sites: list[Site], lectures: dict[str, FauxLecture] | None = None +) -> SiteService: + return SiteService( + sites=FakeRepository(sites), # type: ignore[arg-type] + readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] + ) + + +async def test_list_all_returns_the_repository_sites() -> None: + svc = service([site("a"), site("b")]) + + sites = await svc.list_all() assert [s.site_id for s in sites] == ["a", "b"] async def test_get_by_id_returns_the_matching_site() -> None: - service = SiteService(sites=FakeRepository([site("a")])) + svc = service([site("a")]) - trouve = await service.get_by_id("a") + trouve = await svc.get_by_id("a") assert trouve.site_id == "a" async def test_get_by_id_raises_when_the_site_is_unknown() -> None: - service = SiteService(sites=FakeRepository([])) + svc = service([]) with pytest.raises(SiteNotFoundError): - await service.get_by_id("inconnu") + await svc.get_by_id("inconnu") + + +async def test_current_raises_when_the_site_is_unknown() -> None: + svc = service([]) + + with pytest.raises(SiteNotFoundError): + await svc.current("inconnu") + + +async def test_current_returns_every_field_as_null_when_the_site_has_no_reading() -> None: + svc = service([site("a")]) + + actuel = await svc.current("a") + + assert actuel.timestamp is None + assert actuel.consumption_kw is None + assert actuel.data_quality == "critical" + assert actuel.null_reasons == [] + + +async def test_current_copies_every_field_from_the_latest_reading() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a")}) + + actuel = await svc.current("a") + + assert actuel.timestamp == TIMESTAMP + assert actuel.site_type == "industriel" + assert actuel.consumption_kw == 87.34 + assert actuel.voltage_v == 401.2 + assert actuel.data_quality == "good" + + +async def test_current_treats_an_unknown_data_quality_as_critical() -> None: + svc = service([site("a")], {"a": FauxLecture(site_id="a", data_quality=None)}) + + actuel = await svc.current("a") + + assert actuel.data_quality == "critical" diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index fec2794..32253fb 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -142,6 +142,7 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke | POST | `/api/v1/users/{id}/password-reset` | Réinitialise et ferme les sessions. `admin` | 401, 403, 404, 422, 500 | | GET | `/api/v1/sites` | Liste les sites. `lecteur` | 401, 403, 500 | | GET | `/api/v1/sites/{site_id}` | Décrit un site. `lecteur` | 401, 403, 404, 422, 500 | +| GET | `/api/v1/sites/{site_id}/current` | Dernière mesure d'un site. `lecteur` | 401, 403, 404, 422, 500 | | GET | `/api/v1/alerts` | Liste les alertes, filtrable par `site_id` et `severity`. `lecteur` | 401, 403, 422, 500 | | GET | `/api/v1/recommendations` | Liste les recommandations. `lecteur` | 401, 403, 500 | | GET | `/api/v1/recommendations/{recommendation_id}` | Décrit une recommandation. `lecteur` | 401, 403, 404, 422, 500 | @@ -169,7 +170,11 @@ gabarit à la lettre, `recommendation_id` étant un entier plutôt qu'un texte. porte pas `site_id` : elle remonte à un site par sa seule `alert_id`, `alert` n'étant pas encore exposée. `GET /stats/summary` agrège deux repositories (`SiteRepository`, `ReadingRepository`) dans un service dédié plutôt que d'exposer une table : elle n'entre donc pas dans ce gabarit -route-par-table. Le contrat détaillé pour le frontend est dans +route-par-table. `GET /sites/{site_id}/current` reste sur le gabarit `sites`, mais +`SiteService` gagne la même seconde dépendance (`ReadingRepository`) pour restituer la +dernière `Reading` du site : un site connu sans lecture rend `200` avec tous les champs de +mesure à `null` et `data_quality="critical"`, seul un `site_id` absent de la base rend `404`. +Le contrat détaillé pour le frontend est dans [31-contrat-authentification.md](31-contrat-authentification.md). ### `/health/ready` From 2f97e4d4344deb8831559e6cf8a0d74e3061911e Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 16 Sep 2026 15:27:05 +0200 Subject: [PATCH 02/25] fix(backend): corrige formatage ruff et typage mypy sur sites/current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI en échec sur ruff format (ligne trop longue) et mypy (retour Any non annoté, assignation Literal non étroite). Corrige sans changer le comportement. --- apps/backend/app/api/v1/endpoints/sites.py | 4 +--- apps/backend/app/repositories/reading.py | 3 ++- apps/backend/app/services/site.py | 6 +++--- apps/backend/tests/services/test_site.py | 4 +--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/apps/backend/app/api/v1/endpoints/sites.py b/apps/backend/app/api/v1/endpoints/sites.py index 93923e9..5687b33 100644 --- a/apps/backend/app/api/v1/endpoints/sites.py +++ b/apps/backend/app/api/v1/endpoints/sites.py @@ -42,9 +42,7 @@ async def get_site(site_id: str, _: LecteurDep, service: SiteServiceDep) -> Site summary="Dernière mesure d'un site", responses=REPONSES_INTROUVABLE, ) -async def get_current( - site_id: str, _: LecteurDep, service: SiteServiceDep -) -> SiteCurrentResponse: +async def get_current(site_id: str, _: LecteurDep, service: SiteServiceDep) -> SiteCurrentResponse: try: actuel = await service.current(site_id) except SiteNotFoundError as erreur: diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index c05ae60..7981b9e 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -27,4 +27,5 @@ class ReadingRepository: .order_by(Reading.timestamp.desc()) .limit(1) ) - return await self._session.scalar(requete) + lecture: Reading | None = await self._session.scalar(requete) + return lecture diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 25a819d..50d2e24 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -70,9 +70,9 @@ class SiteService: data_quality="critical", ) - qualite: DataQuality = ( - derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" - ) + qualite: DataQuality = "critical" + if derniere.data_quality in QUALITES_CONNUES: + qualite = derniere.data_quality # type: ignore[assignment] return SiteCurrentReading( timestamp=derniere.timestamp, site_id=site.site_id, diff --git a/apps/backend/tests/services/test_site.py b/apps/backend/tests/services/test_site.py index 7e555e8..76584fb 100644 --- a/apps/backend/tests/services/test_site.py +++ b/apps/backend/tests/services/test_site.py @@ -54,9 +54,7 @@ class FauxDepotLectures: return self._lectures.get(site_id) -def service( - sites: list[Site], lectures: dict[str, FauxLecture] | None = None -) -> SiteService: +def service(sites: list[Site], lectures: dict[str, FauxLecture] | None = None) -> SiteService: return SiteService( sites=FakeRepository(sites), # type: ignore[arg-type] readings=FauxDepotLectures(lectures or {}), # type: ignore[arg-type] From 8e07168a5e4208dded74fdb8cd5052708aafe6ce Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 12:19:26 +0200 Subject: [PATCH 03/25] =?UTF-8?q?test:=20ajout=20de=20propri=C3=A9t=C3=A9s?= =?UTF-8?q?=20dans=20le=20fichier=20de=20config=20pour=20sonarqube?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sonar-project.properties | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 3c1af86..c2cab78 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,14 +1,13 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision - - -# This is the name and version displayed in the SonarCloud UI. -#sonar.projectName=ProjetPiscine_EnerVision -#sonar.projectVersion=1.0 - +sonar.sourceEncoding=UTF-8 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -#sonar.sources=. +sonar.sources=frontend/src,backend/src +# Répertoire contenant les TU +sonar.tests=frontend/tests,backend/tests,frontend/tests/**,backend/tests/** + +sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info,backend/coverage/lcov.info # Encoding of the source code. Default is default system encoding #sonar.sourceEncoding=UTF-8 \ No newline at end of file From 34f35f3ca015cd95b21edc46ed36c856ea02a13c Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 12:26:04 +0200 Subject: [PATCH 04/25] test: sonarqube --- .github/workflows/frontend.yml | 13 ------------- sonar-project.properties | 6 +++--- 2 files changed, 3 insertions(+), 16 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 98d5d53..2259958 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -2,19 +2,6 @@ name: Frontend # Pipeline à choix multiple on: - # workflow_dispatch -> lancement manuel des jobs - workflow_dispatch: - inputs: - job_choice: - required: true - description: "Choix du job" - type: choice - default: all - options: - - build - - sonarqube - - test - - all # lancer tous les jobs push: paths: - "apps/frontend/**" diff --git a/sonar-project.properties b/sonar-project.properties index c2cab78..60f4e30 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,11 +3,11 @@ sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -sonar.sources=frontend/src,backend/src +sonar.sources=apps/frontend/src,apps/backend/src # Répertoire contenant les TU -sonar.tests=frontend/tests,backend/tests,frontend/tests/**,backend/tests/** +sonar.tests=apps/frontend/tests,apps/backend/tests,apps/frontend/tests/**,apps/backend/tests/** -sonar.javascript.lcov.reportPaths=frontend/coverage/lcov.info,backend/coverage/lcov.info +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/lcov.info,apps/backend/coverage/lcov.info # Encoding of the source code. Default is default system encoding #sonar.sourceEncoding=UTF-8 \ No newline at end of file From 00ef725249cdd52a9e53f20a5b889a6205928dc7 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 12:46:04 +0200 Subject: [PATCH 05/25] test: sonarqube --- .github/workflows/frontend.yml | 7 ++++++- sonar-project.properties | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 2259958..ff28d1a 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -41,8 +41,13 @@ jobs: cache-dependency-path: apps/frontend/package-lock.json - run: npm ci working-directory: apps/frontend - - run: npm test -- --watch=false + - run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend + - name: Download frontend coverage + uses: actions/download-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage sonarqube: needs: [build, test] diff --git a/sonar-project.properties b/sonar-project.properties index 60f4e30..b32eaf5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -8,6 +8,7 @@ sonar.sources=apps/frontend/src,apps/backend/src # Répertoire contenant les TU sonar.tests=apps/frontend/tests,apps/backend/tests,apps/frontend/tests/**,apps/backend/tests/** -sonar.javascript.lcov.reportPaths=apps/frontend/coverage/lcov.info,apps/backend/coverage/lcov.info -# Encoding of the source code. Default is default system encoding -#sonar.sourceEncoding=UTF-8 \ No newline at end of file +sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js +sonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/** + +sonar.junit.reportPaths=apps/frontend/tests/test-results From 41c18a3bb12b261988b30b8670f1483afc3890f8 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 12:47:44 +0200 Subject: [PATCH 06/25] test: sonarqube --- .github/workflows/frontend.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index ff28d1a..b8c0096 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -43,12 +43,6 @@ jobs: working-directory: apps/frontend - run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend - - name: Download frontend coverage - uses: actions/download-artifact@v4 - with: - name: frontend-coverage - path: apps/frontend/coverage - sonarqube: needs: [build, test] name: SonarQube From 11f9b1bcd5c94a7dae83d0126b77bec7f8ee7383 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 13:04:36 +0200 Subject: [PATCH 07/25] test(frontend): sonarqube --- .github/workflows/frontend.yml | 1 + sonar-project.properties | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index b8c0096..586974a 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -43,6 +43,7 @@ jobs: working-directory: apps/frontend - run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend + sonarqube: needs: [build, test] name: SonarQube diff --git a/sonar-project.properties b/sonar-project.properties index b32eaf5..100dfd5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,10 +3,10 @@ sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -sonar.sources=apps/frontend/src,apps/backend/src +sonar.sources=apps/frontend/src # Répertoire contenant les TU -sonar.tests=apps/frontend/tests,apps/backend/tests,apps/frontend/tests/**,apps/backend/tests/** +sonar.tests=apps/frontend/src sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js sonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/** From ff68a51424a2f2a115ffccc025a5d421cadb4b05 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 13:36:21 +0200 Subject: [PATCH 08/25] feat(frontend): fichier de config pour vitest --- apps/frontend/vitest.config.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 apps/frontend/vitest.config.ts diff --git a/apps/frontend/vitest.config.ts b/apps/frontend/vitest.config.ts new file mode 100644 index 0000000..e6ea512 --- /dev/null +++ b/apps/frontend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + reportsDirectory: './coverage', + include: ['src/**/*.{ts,tsx,js,jsx}'], + exclude: [ + '**/*.spec.*', + '**/*.test.*', + '**/node_modules/**', + '**/dist/**', + ], + }, + }, +}) \ No newline at end of file From ed7311d4efde47bb275e7463da9d208706734883 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 13:36:41 +0200 Subject: [PATCH 09/25] test: configuration sonarqube --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 100dfd5..18de3f3 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,4 +11,4 @@ sonar.tests=apps/frontend/src sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js sonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/** -sonar.junit.reportPaths=apps/frontend/tests/test-results +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/lcov.info From 278299c2b1535677a568ec385affce2416040576 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 14:18:54 +0200 Subject: [PATCH 10/25] test: configuration sonarqube + config angular --- apps/frontend/angular.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 6cb33fa..981779f 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -86,15 +86,6 @@ "text-summary", "lcov", "html" - ], - "reporters": [ - "default", - [ - "junit", - { - "outputFile": "test-results/junit.xml" - } - ] ] } } From 2465021d61e01ade4339f6d9e2dd514f4c182748 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 14:22:30 +0200 Subject: [PATCH 11/25] fix(frontend): chemin vers lcov.info --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 18de3f3..ee530ac 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,4 +11,4 @@ sonar.tests=apps/frontend/src sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js sonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/** -sonar.javascript.lcov.reportPaths=apps/frontend/coverage/lcov.info +sonar.javascript.lcov.reportPaths=apps/frontend/coverage//frontend/lcov.info From 334ca5982b781d929142d961b11fe5a95eae4c8a Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 14:26:22 +0200 Subject: [PATCH 12/25] fix(frontend): chemin vers lcov.info --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index ee530ac..adf2309 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,4 +11,4 @@ sonar.tests=apps/frontend/src sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js sonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/** -sonar.javascript.lcov.reportPaths=apps/frontend/coverage//frontend/lcov.info +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From 4ee210962862b83a334c73b871d1ce5b64945ff8 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 14:32:14 +0200 Subject: [PATCH 13/25] =?UTF-8?q?fix(frontend):=20prise=20en=20compte=20du?= =?UTF-8?q?=20r=C3=A9pertoire=20du=20reporter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index adf2309..9ffcb8f 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -sonar.sources=apps/frontend/src +sonar.sources=apps/frontend/src,apps/frontend/coverage # Répertoire contenant les TU sonar.tests=apps/frontend/src From 7bc9a09489d1bfdc7eba6fb96105700d99646542 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 14:38:08 +0200 Subject: [PATCH 14/25] =?UTF-8?q?fix(frontend):=20mise=20=C3=A0=20jour=20d?= =?UTF-8?q?es=20propri=C3=A9t=C3=A9s=20sonar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sonar-project.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 9ffcb8f..78c8162 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,12 +3,12 @@ sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -sonar.sources=apps/frontend/src,apps/frontend/coverage +sonar.sources=apps/frontend/src,apps/frontend/** # Répertoire contenant les TU sonar.tests=apps/frontend/src sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js -sonar.exclusions=**/node_modules/**,**/dist/**,**/coverage/** +sonar.exclusions=**/node_modules/**,**/dist/** sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From 1afaee069f47681a9d9d0c8c0af8a6567c404854 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 14:39:48 +0200 Subject: [PATCH 15/25] =?UTF-8?q?fix(frontend):=20mise=20=C3=A0=20jour=20d?= =?UTF-8?q?es=20propri=C3=A9t=C3=A9s=20sonar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 78c8162..fd1dece 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,7 @@ sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -sonar.sources=apps/frontend/src,apps/frontend/** +sonar.sources=apps/frontend/src,apps/frontend # Répertoire contenant les TU sonar.tests=apps/frontend/src From 1fce577a789229db4be29e27e8c6f00179e03114 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 14:45:54 +0200 Subject: [PATCH 16/25] =?UTF-8?q?fix(frontend):=20mise=20=C3=A0=20jour=20d?= =?UTF-8?q?es=20propri=C3=A9t=C3=A9s=20sonar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sonar-project.properties | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index fd1dece..2a9adfd 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,13 +2,10 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 -# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. -sonar.sources=apps/frontend/src,apps/frontend - -# Répertoire contenant les TU +sonar.sources=apps/frontend/src sonar.tests=apps/frontend/src sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js -sonar.exclusions=**/node_modules/**,**/dist/** +sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From 19cfac1cffd58e7d259789ab87e3397c42759789 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Thu, 17 Sep 2026 14:48:52 +0200 Subject: [PATCH 17/25] =?UTF-8?q?fix(frontend):=20mise=20=C3=A0=20jour=20d?= =?UTF-8?q?es=20propri=C3=A9t=C3=A9s=20sonar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 2a9adfd..0be1a3d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,7 +2,7 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 -sonar.sources=apps/frontend/src +sonar.sources=apps/frontend/src,apps/frontend sonar.tests=apps/frontend/src sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js From 5875e8c23936674de9010125d88375170ab8e007 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 10:01:28 +0200 Subject: [PATCH 18/25] test: sonarqube --- sonar-project.properties | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 0be1a3d..17c5a87 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,10 +2,8 @@ sonar.projectKey=ProjetPiscine_EnerVision sonar.organization=groupe3-ener-vision sonar.sourceEncoding=UTF-8 -sonar.sources=apps/frontend/src,apps/frontend -sonar.tests=apps/frontend/src +sonar.sources=apps/frontend/src,apps/backend/app -sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js -sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.ts,**/*.test.ts,**/*.spec.js,**/*.test.js +sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From c1f63889c1f09f36808ce711dc8cba07ef264e79 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 10:10:05 +0200 Subject: [PATCH 19/25] test: sonarqube --- sonar-project.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sonar-project.properties b/sonar-project.properties index 17c5a87..87c63d2 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,6 +4,6 @@ sonar.sourceEncoding=UTF-8 sonar.sources=apps/frontend/src,apps/backend/app -sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js +sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json -sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend From 6cb9ac00cb7e8fac7c372440beca9353eeeb0ddf Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 10:14:06 +0200 Subject: [PATCH 20/25] test: sonarqube --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 87c63d2..2e73864 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,4 +6,4 @@ sonar.sources=apps/frontend/src,apps/backend/app sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json -sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend +sonar.javascript.lcov.reportPaths=/home/runner/work/ProjetPiscine_EnerVision/ProjetPiscine_EnerVision/apps/frontend/coverage/frontend From 9e33c276d65c2b527814abe088d586e15284bd20 Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 10:16:31 +0200 Subject: [PATCH 21/25] test: sonarqube --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index 2e73864..a2ce41a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,4 +6,4 @@ sonar.sources=apps/frontend/src,apps/backend/app sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json -sonar.javascript.lcov.reportPaths=/home/runner/work/ProjetPiscine_EnerVision/ProjetPiscine_EnerVision/apps/frontend/coverage/frontend +sonar.javascript.lcov.reportPaths=/home/runner/work/ProjetPiscine_EnerVision/ProjetPiscine_EnerVision/apps/frontend/coverage/frontend/lcov.info From 2400b6f05e2f974212d0ff3e6695e666cf06c7db Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 10:21:56 +0200 Subject: [PATCH 22/25] test: sonarqube --- .github/workflows/frontend.yml | 24 ++++++++++++++---------- sonar-project.properties | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 586974a..6bcb72c 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -41,24 +41,28 @@ jobs: cache-dependency-path: apps/frontend/package-lock.json - run: npm ci working-directory: apps/frontend - - run: npm test --watch=false --code-coverage --coverageReporters=lcov + - run: npm test -- --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage/frontend/lcov.info sonarqube: needs: [build, test] name: SonarQube runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@v6 with: - fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + fetch-depth: 0 + - name: Download coverage + uses: actions/download-artifact@v4 + with: + name: frontend-coverage + path: apps/frontend/coverage/frontend - name: SonarQube Scan - uses: SonarSource/sonarqube-scan-action@7006c4492b2e0ee0f816d36501671557c97f5995 # v8.1.0 + uses: SonarSource/sonarqube-scan-action@v8 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - - # deploy: - # runs-on: ubuntu-latest - # steps: - # - run: echo "DEPLOY job is running" diff --git a/sonar-project.properties b/sonar-project.properties index a2ce41a..7155d42 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,4 +6,4 @@ sonar.sources=apps/frontend/src,apps/backend/app sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json -sonar.javascript.lcov.reportPaths=/home/runner/work/ProjetPiscine_EnerVision/ProjetPiscine_EnerVision/apps/frontend/coverage/frontend/lcov.info +sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info From cc0a58ac4cdb20a8d8732ec39ead2554de3a7a1b Mon Sep 17 00:00:00 2001 From: ineszang44 Date: Fri, 18 Sep 2026 10:23:09 +0200 Subject: [PATCH 23/25] test: sonarqube --- .github/workflows/frontend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml index 6bcb72c..aff8d71 100644 --- a/.github/workflows/frontend.yml +++ b/.github/workflows/frontend.yml @@ -41,7 +41,7 @@ jobs: cache-dependency-path: apps/frontend/package-lock.json - run: npm ci working-directory: apps/frontend - - run: npm test -- --watch=false --code-coverage --coverageReporters=lcov + - run: npm test --watch=false --code-coverage --coverageReporters=lcov working-directory: apps/frontend - name: Upload coverage uses: actions/upload-artifact@v4 From 5eb74aa64a6146056800187318df9e42ade90692 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 10:23:28 +0200 Subject: [PATCH 24/25] fix(backend): traite la revue de phyri0s sur la PR #84 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tri non déterministe : `latest_for_site` départage désormais les égalités de timestamp par `reading_id` décroissant, comme `list_history`. `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère, donc le `LIMIT 1` pouvait renvoyer l'une ou l'autre d'un appel à l'autre. Tests : trois tests `integration` sur `latest_for_site` (plus récente, égalité de timestamp, isolation par site). Le test d'égalité échoue sans le correctif ci-dessus. Duplication : `DataQuality` et le repli vers `critical` sortent dans `app/services/data_quality.py`, partagé par `stats.py`, `site.py` et `sensor.py`, qui en portaient trois copies indépendantes. Supprime au passage deux `# type: ignore[assignment]`. --- apps/backend/app/repositories/reading.py | 4 +- apps/backend/app/services/data_quality.py | 18 +++++++ apps/backend/app/services/sensor.py | 5 +- apps/backend/app/services/site.py | 11 +---- apps/backend/app/services/stats.py | 11 +---- .../tests/repositories/test_reading.py | 47 +++++++++++++++++++ 6 files changed, 74 insertions(+), 22 deletions(-) create mode 100644 apps/backend/app/services/data_quality.py diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index e992358..d251f55 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -22,10 +22,12 @@ class ReadingRepository: return (await self._session.execute(requete)).scalars().all() async def latest_for_site(self, site_id: str) -> Reading | None: + # Piège : `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la + # `source` diffère. Sans `reading_id` en départage, le `LIMIT 1` renverrait au hasard. requete = ( select(Reading) .where(Reading.site_id == site_id) - .order_by(Reading.timestamp.desc()) + .order_by(Reading.timestamp.desc(), Reading.reading_id.desc()) .limit(1) ) lecture: Reading | None = await self._session.scalar(requete) diff --git a/apps/backend/app/services/data_quality.py b/apps/backend/app/services/data_quality.py new file mode 100644 index 0000000..ae8b43c --- /dev/null +++ b/apps/backend/app/services/data_quality.py @@ -0,0 +1,18 @@ +# Contrainte : `ck_reading_quality` accepte NULL et quatre valeurs seulement, alors que le contrat +# frontend n'a aucune valeur pour l'absence de qualité. `qualite_ou_critique()` replie donc sur +# `critical`, la seule des quatre qui n'induise pas une confiance qu'on n'a pas. `QUALITES_CONNUES` +# reste exposé pour les appelants qui doivent distinguer un `critical` stocké d'un repli. + +from typing import Literal, get_args + +DataQuality = Literal["good", "partial", "degraded", "critical"] + +QUALITES_CONNUES: frozenset[str] = frozenset(get_args(DataQuality)) + +_PAR_VALEUR: dict[str, DataQuality] = {valeur: valeur for valeur in get_args(DataQuality)} + + +def qualite_ou_critique(valeur: str | None) -> DataQuality: + if valeur is None: + return "critical" + return _PAR_VALEUR.get(valeur, "critical") diff --git a/apps/backend/app/services/sensor.py b/apps/backend/app/services/sensor.py index 1d707e0..fa1a1ea 100644 --- a/apps/backend/app/services/sensor.py +++ b/apps/backend/app/services/sensor.py @@ -5,12 +5,11 @@ from typing import Literal from app.models.energy import Reading, Site from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository +from app.services.data_quality import qualite_ou_critique CapteurStatus = Literal["ok", "failing"] OverallStatus = Literal["ok", "degraded", "critical"] -QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) - RAISON_VERS_CAPTEUR: dict[str, str] = { "consumption_sensor_failure": "consumption", "electrical_sensor_failure": "electrical", @@ -80,7 +79,7 @@ def _sante_site(site: Site, derniere: Reading | None) -> SanteSite: overall="critical", ) - qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical" + qualite = qualite_ou_critique(derniere.data_quality) overall = _overall_depuis_qualite(qualite) if overall == "critical": diff --git a/apps/backend/app/services/site.py b/apps/backend/app/services/site.py index 50d2e24..a438a20 100644 --- a/apps/backend/app/services/site.py +++ b/apps/backend/app/services/site.py @@ -1,15 +1,11 @@ from collections.abc import Sequence from dataclasses import dataclass from datetime import datetime -from typing import Literal from app.models.energy import Site from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository - -DataQuality = Literal["good", "partial", "degraded", "critical"] - -QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) +from app.services.data_quality import DataQuality, qualite_ou_critique class SiteError(Exception): @@ -70,9 +66,6 @@ class SiteService: data_quality="critical", ) - qualite: DataQuality = "critical" - if derniere.data_quality in QUALITES_CONNUES: - qualite = derniere.data_quality # type: ignore[assignment] return SiteCurrentReading( timestamp=derniere.timestamp, site_id=site.site_id, @@ -85,5 +78,5 @@ class SiteService: temperature_celsius=derniere.temperature_celsius, humidity_percent=derniere.humidity_percent, null_reasons=derniere.null_reasons or [], - data_quality=qualite, + data_quality=qualite_ou_critique(derniere.data_quality), ) diff --git a/apps/backend/app/services/stats.py b/apps/backend/app/services/stats.py index c98eace..c2b15da 100644 --- a/apps/backend/app/services/stats.py +++ b/apps/backend/app/services/stats.py @@ -1,14 +1,10 @@ from dataclasses import dataclass from datetime import UTC, datetime -from typing import Literal from app.models.energy import Reading, Site from app.repositories.reading import ReadingRepository from app.repositories.site import SiteRepository - -DataQuality = Literal["good", "partial", "degraded", "critical"] - -QUALITES_CONNUES: frozenset[str] = frozenset({"good", "partial", "degraded", "critical"}) +from app.services.data_quality import QUALITES_CONNUES, DataQuality, qualite_ou_critique @dataclass(frozen=True, slots=True) @@ -58,13 +54,10 @@ class StatsService: @staticmethod def _resume_site(site: Site, derniere: Reading | None) -> SiteConsumption: capacite = site.capacity_kw or 0 - # Piège : `data_quality` est nul dès qu'un site n'a jamais reçu de lecture, ou que le - # producteur n'a pas su la qualifier. Le contrat frontend n'a pas de valeur pour ce cas, - # `critical` est la seule des quatre qui n'induit pas une confiance qu'on n'a pas. qualite: DataQuality = "critical" consommation = None if derniere is not None and derniere.data_quality in QUALITES_CONNUES: - qualite = derniere.data_quality # type: ignore[assignment] + qualite = qualite_ou_critique(derniere.data_quality) consommation = derniere.consumption_kw charge = ( diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index 150fa29..dd2f2df 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -88,6 +88,53 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> assert identifiants == {premier, second} +async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 9, 1, tzinfo=UTC)) + recente = await creer_lecture( + session, site_id=site.site_id, timestamp=datetime(2026, 9, 15, tzinfo=UTC) + ) + + trouvee = await depot.latest_for_site(site.site_id) + reading_id = trouvee.reading_id if trouvee else None + await session.rollback() + + assert reading_id == recente.reading_id + + +async def test_latest_for_site_breaks_a_timestamp_tie_on_the_last_written_reading( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 15, tzinfo=UTC) + await creer_lecture(session, site_id=site.site_id, timestamp=horodatage, source="api_history") + derniere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current" + ) + + trouvee = await depot.latest_for_site(site.site_id) + reading_id = trouvee.reading_id if trouvee else None + await session.rollback() + + assert reading_id == derniere.reading_id + + +async def test_latest_for_site_ignores_the_readings_of_the_other_sites( + session: AsyncSession, +) -> None: + sans_lecture = await creer_site(session) + autre = await creer_site(session) + depot = ReadingRepository(session) + await creer_lecture(session, site_id=autre.site_id) + + trouvee = await depot.latest_for_site(sans_lecture.site_id) + await session.rollback() + + assert trouvee is None + + async def test_list_history_orders_the_readings_by_timestamp_descending( session: AsyncSession, ) -> None: From b433e01fa8cb763d4b97ecedb37b3161e3618983 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Fri, 18 Sep 2026 10:26:18 +0200 Subject: [PATCH 25/25] =?UTF-8?q?fix(backend):=20d=C3=A9partage=20aussi=20?= =?UTF-8?q?les=20=C3=A9galit=C3=A9s=20de=20timestamp=20dans=20latest=5Fby?= =?UTF-8?q?=5Fsite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `latest_by_site` portait le même défaut que `latest_for_site` : `DISTINCT ON (site_id)` ordonné sur `site_id, timestamp DESC` sans départage, alors que `uq_reading_source` autorise deux lignes au même `site_id`+`timestamp` quand la `source` diffère. `/stats/summary` pouvait donc afficher une consommation différente d'un appel à l'autre pour un site alimenté par un backfill CSV et une écriture live. Test `integration` dédié, qui échoue sans le correctif. --- apps/backend/app/repositories/reading.py | 5 +++-- .../tests/repositories/test_reading.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/backend/app/repositories/reading.py b/apps/backend/app/repositories/reading.py index d251f55..d005d16 100644 --- a/apps/backend/app/repositories/reading.py +++ b/apps/backend/app/repositories/reading.py @@ -13,11 +13,12 @@ class ReadingRepository: async def latest_by_site(self) -> Sequence[Reading]: # `.distinct(site_id)` compile en `DISTINCT ON (site_id)` sous PostgreSQL : une seule - # ligne par site, la plus récente grâce à l'ordre composite qui suit. + # ligne par site, la plus récente grâce à l'ordre composite qui suit. `reading_id` départage + # les égalités de timestamp, que `uq_reading_source` autorise à `source` différente. requete = ( select(Reading) .distinct(Reading.site_id) - .order_by(Reading.site_id, Reading.timestamp.desc()) + .order_by(Reading.site_id, Reading.timestamp.desc(), Reading.reading_id.desc()) ) return (await self._session.execute(requete)).scalars().all() diff --git a/apps/backend/tests/repositories/test_reading.py b/apps/backend/tests/repositories/test_reading.py index dd2f2df..4f12df0 100644 --- a/apps/backend/tests/repositories/test_reading.py +++ b/apps/backend/tests/repositories/test_reading.py @@ -88,6 +88,26 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) -> assert identifiants == {premier, second} +async def test_latest_by_site_breaks_a_timestamp_tie_on_the_last_written_reading( + session: AsyncSession, +) -> None: + site = await creer_site(session) + depot = ReadingRepository(session) + horodatage = datetime(2026, 9, 15, tzinfo=UTC) + await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_history", consumption_kw=10 + ) + derniere = await creer_lecture( + session, site_id=site.site_id, timestamp=horodatage, source="api_current", consumption_kw=42 + ) + + resultats = await depot.latest_by_site() + retenues = [r.reading_id for r in resultats if r.site_id == site.site_id] + await session.rollback() + + assert retenues == [derniere.reading_id] + + async def test_latest_for_site_returns_the_most_recent_reading(session: AsyncSession) -> None: site = await creer_site(session) depot = ReadingRepository(session)