Merge remote-tracking branch 'origin/dev' into feat/endpoint-sites-current

# Conflicts:
#	apps/backend/app/repositories/reading.py
#	apps/backend/openapi.json
#	docs/architecture/20-backend.md
This commit is contained in:
Johan LEROY
2026-09-17 12:17:05 +02:00
58 changed files with 4556 additions and 41 deletions
+16
View File
@@ -31,7 +31,9 @@ from app.repositories.site import SiteRepository
from app.repositories.user import UserRepository
from app.services.alert import AlertService
from app.services.auth import AuthService, LoginPolicy
from app.services.reading import ReadingService
from app.services.recommendation import RecommendationService
from app.services.sensor import SensorService
from app.services.site import SiteService
from app.services.stats import StatsService
from app.services.user import UserService
@@ -167,6 +169,20 @@ def get_stats_service(session: SessionDep) -> StatsService:
StatsServiceDep = Annotated[StatsService, Depends(get_stats_service)]
def get_reading_service(session: SessionDep) -> ReadingService:
return ReadingService(readings=ReadingRepository(session))
ReadingServiceDep = Annotated[ReadingService, Depends(get_reading_service)]
def get_sensor_service(session: SessionDep) -> SensorService:
return SensorService(sites=SiteRepository(session), readings=ReadingRepository(session))
SensorServiceDep = Annotated[SensorService, Depends(get_sensor_service)]
async def get_current_principal(
credentials: CredentialsDep,
session: SessionDep,
+12
View File
@@ -71,6 +71,18 @@ TAGS: Final[list[dict[str, Any]]] = [
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle "
"`lecteur`.",
},
{
"name": "readings",
"description": (
"Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, "
"24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du "
"rôle `lecteur`."
),
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`.",
},
]
cookie_de_rafraichissement = APIKeyCookie(
@@ -0,0 +1,54 @@
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, status
from app.api.deps import LecteurDep, ReadingServiceDep
from app.api.openapi import REPONSE_VALIDATION, Reponses
from app.schemas.errors import ErrorResponse
from app.schemas.reading import ReadingResponse
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
router = APIRouter()
REPONSES_FENETRE: Reponses = {
**REPONSE_VALIDATION,
400: {
"model": ErrorResponse,
"description": (
"Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre "
"les deux supérieur à 90 jours."
),
},
}
@router.get(
"",
response_model=list[ReadingResponse],
summary="Liste l'historique des lectures",
responses=REPONSES_FENETRE,
)
async def list_readings(
_: LecteurDep,
service: ReadingServiceDep,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int = Query(500, ge=1, le=2000),
offset: int = Query(0, ge=0),
) -> list[ReadingResponse]:
try:
lectures = await service.list_history(
site_id=site_id, start=start, end=end, limit=limit, offset=offset
)
except FenetreInverseeError as erreur:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="`start` doit être strictement antérieur à `end`",
) from erreur
except FenetreTropLargeError as erreur:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="L'écart entre `start` et `end` ne peut pas dépasser 90 jours",
) from erreur
return [ReadingResponse.model_validate(lecture) for lecture in lectures]
@@ -0,0 +1,16 @@
from fastapi import APIRouter
from app.api.deps import AdminDep, SensorServiceDep
from app.schemas.sensor import SensorStatusResponse
router = APIRouter()
@router.get(
"/status",
response_model=SensorStatusResponse,
summary="État de santé des capteurs par site",
)
async def get_status(_: AdminDep, service: SensorServiceDep) -> SensorStatusResponse:
etat = await service.status()
return SensorStatusResponse.model_validate(etat)
+17 -1
View File
@@ -1,7 +1,17 @@
from fastapi import APIRouter
from app.api.openapi import REPONSE_SERVEUR, REPONSES_ADMIN, REPONSES_LECTEUR
from app.api.v1.endpoints import alerts, auth, health, recommendations, sites, stats, users
from app.api.v1.endpoints import (
alerts,
auth,
health,
readings,
recommendations,
sensors,
sites,
stats,
users,
)
api_router = APIRouter(responses=REPONSE_SERVEUR)
api_router.include_router(health.router, prefix="/health", tags=["health"])
@@ -18,3 +28,9 @@ api_router.include_router(
responses=REPONSES_LECTEUR,
)
api_router.include_router(stats.router, prefix="/stats", tags=["stats"], responses=REPONSES_LECTEUR)
api_router.include_router(
readings.router, prefix="/readings", tags=["readings"], responses=REPONSES_LECTEUR
)
api_router.include_router(
sensors.router, prefix="/sensors", tags=["sensors"], responses=REPONSES_ADMIN
)
View File
+621
View File
@@ -0,0 +1,621 @@
from __future__ import annotations
import argparse
import asyncio
import hashlib
import json
from pathlib import Path
from typing import Any, cast
import pandas as pd
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine
from app.core.config import get_settings
REQUIRED_COLUMNS = {
"timestamp",
"site_id",
"site_type",
"site_name",
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"hour",
"day_of_week",
"day_name",
"month",
"is_weekend",
"is_working_hours",
}
MEASURE_COLUMNS = [
"consumption_kwh",
"consumption_euros",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
]
SOURCE_NAME = "csv"
def compute_sha256(path: Path) -> str:
"""Calcule l'empreinte SHA-256 du fichier source."""
sha256 = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
sha256.update(block)
return sha256.hexdigest()
def load_metadata(path: Path) -> dict[str, Any]:
"""Charge les métadonnées fournies avec le dataset."""
with path.open("r", encoding="utf-8") as source:
metadata = json.load(source)
if not isinstance(metadata, dict):
raise ValueError("Le fichier de métadonnées doit contenir un objet JSON.")
return cast(dict[str, Any], metadata)
def classify_quality(
row: dict[str, Any],
) -> tuple[str, list[str]]:
"""
Déduit une qualité technique à partir des champs manquants.
Les valeurs NULL sont conservées. On ne cherche pas ici à
déterminer la cause physique exacte de leur absence.
"""
missing = [column for column in MEASURE_COLUMNS if pd.isna(row.get(column))]
if not missing:
quality = "good"
elif len(missing) == len(MEASURE_COLUMNS):
quality = "critical"
elif "consumption_kwh" in missing:
quality = "degraded"
else:
quality = "partial"
reasons = [f"missing:{column}" for column in missing]
return quality, reasons
def validate_source(
frame: pd.DataFrame,
metadata: dict[str, Any],
) -> None:
"""Valide le dataset avant tout chargement en base."""
missing_columns = REQUIRED_COLUMNS.difference(frame.columns)
if missing_columns:
raise ValueError(f"Colonnes obligatoires absentes : {sorted(missing_columns)}")
expected_records = int(metadata["total_records"])
if len(frame) != expected_records:
raise ValueError(f"Nombre de lignes inattendu : {len(frame)} au lieu de {expected_records}")
expected_sites = set(metadata["sites"].keys())
actual_sites = set(frame["site_id"].unique())
if actual_sites != expected_sites:
raise ValueError(
f"Sites incohérents. Attendus={sorted(expected_sites)}, trouvés={sorted(actual_sites)}"
)
duplicated = frame.duplicated(subset=["site_id", "timestamp"]).sum()
if duplicated:
raise ValueError(f"{duplicated} doublons (site_id, timestamp) détectés")
static_variants = frame.groupby("site_id")[["site_type", "site_name"]].nunique()
if (static_variants > 1).any().any():
raise ValueError("Un site possède plusieurs valeurs de site_type ou site_name.")
# Vérifie également que tous les timestamps
# peuvent être interprétés correctement.
pd.to_datetime(
frame["timestamp"],
errors="raise",
)
def normalize_timestamps(
frame: pd.DataFrame,
source_timezone: str,
) -> pd.DataFrame:
"""
Normalise les timestamps et leur associe une timezone.
Les timestamps originaux sont conservés dans une colonne
temporaire afin de pouvoir les stocker dans raw_data.
"""
normalized = frame.copy()
normalized["_source_timestamp"] = normalized["timestamp"]
timestamps = pd.to_datetime(
normalized["timestamp"],
errors="raise",
)
if timestamps.dt.tz is None:
timestamps = timestamps.dt.tz_localize(source_timezone)
else:
timestamps = timestamps.dt.tz_convert(source_timezone)
normalized["timestamp"] = timestamps
return normalized
def to_json_value(value: Any) -> Any:
"""
Convertit une valeur Pandas/Numpy en valeur
compatible JSON.
"""
if value is None:
return None
try:
if pd.isna(value):
return None
except TypeError, ValueError:
pass
if isinstance(value, pd.Timestamp):
return value.isoformat()
if hasattr(value, "item"):
return value.item()
return value
async def ensure_dataset(
connection: AsyncConnection,
metadata: dict[str, Any],
sha256: str,
source_timezone: str,
storage_uri: str,
) -> int:
"""
Crée l'entrée dataset si elle n'existe pas.
Le SHA-256 permet de reconnaître un fichier déjà importé
et participe à l'idempotence et à la traçabilité.
"""
result = await connection.execute(
text(
"""
SELECT dataset_id
FROM dataset
WHERE archive_sha256 = :sha256
LIMIT 1
"""
),
{
"sha256": sha256,
},
)
existing = result.scalar_one_or_none()
if existing is not None:
return int(existing)
metadata_summary = {
"generator_version": metadata.get("generator_version"),
"total_sites": metadata.get("total_sites"),
"total_records": metadata.get("total_records"),
"date_range": metadata.get("date_range"),
"frequency": metadata.get("frequency"),
"null_injection_enabled": metadata.get("null_injection_enabled"),
"null_strategies": metadata.get("null_strategies"),
"importer": "historical_import_v1",
}
result = await connection.execute(
text(
"""
INSERT INTO dataset (
dataset_name,
archive_sha256,
storage_uri,
source_timezone,
"metadata"
)
VALUES (
:dataset_name,
:archive_sha256,
:storage_uri,
:source_timezone,
CAST(:metadata AS jsonb)
)
RETURNING dataset_id
"""
),
{
"dataset_name": ("EnerVision historical dataset 2023-2024"),
"archive_sha256": sha256,
"storage_uri": storage_uri,
"source_timezone": source_timezone,
"metadata": json.dumps(
metadata_summary,
ensure_ascii=False,
),
},
)
return int(result.scalar_one())
async def upsert_sites(
connection: AsyncConnection,
frame: pd.DataFrame,
) -> None:
"""Insère ou met à jour les sites du dataset."""
sites = cast(
list[dict[str, Any]],
frame[
[
"site_id",
"site_type",
"site_name",
]
]
.drop_duplicates(subset=["site_id"])
.to_dict(orient="records"),
)
await connection.execute(
text(
"""
INSERT INTO site (
site_id,
site_type,
site_name
)
VALUES (
:site_id,
:site_type,
:site_name
)
ON CONFLICT (site_id)
DO UPDATE SET
site_type = EXCLUDED.site_type,
site_name = EXCLUDED.site_name
"""
),
sites,
)
def build_reading_batch(
chunk: pd.DataFrame,
dataset_id: int,
) -> list[dict[str, Any]]:
"""
Transforme un chunk Pandas en lignes prêtes
à être chargées dans la table reading.
"""
rows: list[dict[str, Any]] = []
records = cast(
list[dict[str, Any]],
chunk.to_dict(orient="records"),
)
for record in records:
quality, reasons = classify_quality(record)
raw_data = {
column: to_json_value(value)
for column, value in record.items()
if column != "_source_timestamp"
}
# Dans raw_data, on conserve le timestamp
# exactement tel qu'il était dans le CSV.
raw_data["timestamp"] = to_json_value(record["_source_timestamp"])
rows.append(
{
"site_id": record["site_id"],
"timestamp": record["timestamp"],
"source": SOURCE_NAME,
"dataset_id": dataset_id,
# Non fourni par le dataset historique.
"consumption_kw": None,
"consumption_kwh": to_json_value(record["consumption_kwh"]),
"consumption_euros": to_json_value(record["consumption_euros"]),
# Non fournis par le CSV historique.
"voltage_v": None,
"current_a": None,
"power_factor": None,
"temperature_celsius": (to_json_value(record["temperature_celsius"])),
"humidity_percent": (to_json_value(record["humidity_percent"])),
"solar_irradiance_wm2": (to_json_value(record["solar_irradiance_wm2"])),
"is_working_hours": bool(record["is_working_hours"]),
"data_quality": quality,
"null_reasons": reasons,
# Aucune imputation pendant l'ingestion RAW.
# Les valeurs manquantes sont conservées telles quelles
# afin de préserver la donnée source.
"imputed_values": None,
"imputation_method": None,
# Conservation de la donnée source
# pour la traçabilité.
"raw_data": json.dumps(
raw_data,
ensure_ascii=False,
),
}
)
return rows
READING_INSERT = text(
"""
INSERT INTO reading (
site_id,
timestamp,
source,
dataset_id,
consumption_kw,
consumption_kwh,
consumption_euros,
voltage_v,
current_a,
power_factor,
temperature_celsius,
humidity_percent,
solar_irradiance_wm2,
is_working_hours,
data_quality,
null_reasons,
imputed_values,
imputation_method,
raw_data
)
VALUES (
:site_id,
:timestamp,
:source,
:dataset_id,
:consumption_kw,
:consumption_kwh,
:consumption_euros,
:voltage_v,
:current_a,
:power_factor,
:temperature_celsius,
:humidity_percent,
:solar_irradiance_wm2,
:is_working_hours,
:data_quality,
:null_reasons,
CAST(:imputed_values AS jsonb),
:imputation_method,
CAST(:raw_data AS jsonb)
)
ON CONFLICT DO NOTHING
"""
)
async def import_historical(
csv_path: Path,
metadata_path: Path,
source_timezone: str,
batch_size: int,
dry_run: bool,
storage_uri: str,
) -> None:
"""
Exécute le pipeline ETL historique EnerVision.
Étapes :
1. Extract
2. Validate
3. Transform
4. Load
"""
metadata = load_metadata(metadata_path)
frame = pd.read_csv(csv_path)
validate_source(
frame,
metadata,
)
print(f"Lignes : {len(frame)}")
print(f"Sites : {frame['site_id'].nunique()}")
print(f"Période : {frame['timestamp'].min()} -> {frame['timestamp'].max()}")
print(f"Doublons : {frame.duplicated(['site_id', 'timestamp']).sum()}")
print("\nValeurs NULL :")
print(frame[MEASURE_COLUMNS].isna().sum())
sha256 = compute_sha256(csv_path)
print(f"\nSHA-256 : {sha256}")
if dry_run:
print("\nDry-run terminé : aucune donnée écrite.")
return
normalized = normalize_timestamps(
frame,
source_timezone,
)
settings = get_settings()
engine = create_async_engine(
str(settings.database_url),
pool_pre_ping=True,
)
try:
async with engine.begin() as connection:
dataset_id = await ensure_dataset(
connection=connection,
metadata=metadata,
sha256=sha256,
source_timezone=source_timezone,
storage_uri=storage_uri,
)
await upsert_sites(
connection,
normalized,
)
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
before = int(result.scalar_one())
for start in range(
0,
len(normalized),
batch_size,
):
chunk = normalized.iloc[start : start + batch_size]
rows = build_reading_batch(
chunk,
dataset_id,
)
await connection.execute(
READING_INSERT,
rows,
)
loaded = min(
start + batch_size,
len(normalized),
)
print(f"Chargement : {loaded}/{len(normalized)}")
result = await connection.execute(
text(
"""
SELECT COUNT(*)
FROM reading
WHERE dataset_id = :dataset_id
AND source = :source
"""
),
{
"dataset_id": dataset_id,
"source": SOURCE_NAME,
},
)
after = int(result.scalar_one())
print("\nImport terminé.")
print(f"dataset_id : {dataset_id}")
print(f"lectures avant : {before}")
print(f"lectures après : {after}")
print(f"nouvelles lectures : {after - before}")
finally:
await engine.dispose()
def parse_args() -> argparse.Namespace:
"""Définit les arguments CLI de l'import."""
parser = argparse.ArgumentParser(description=("Import historique EnerVision"))
parser.add_argument(
"--csv",
type=Path,
required=True,
help="Chemin vers le CSV historique.",
)
parser.add_argument(
"--metadata",
type=Path,
required=True,
help=("Chemin vers le fichier dataset_metadata.json."),
)
parser.add_argument(
"--source-timezone",
default="UTC",
help=("Timezone associée aux timestamps du dataset. Défaut : UTC."),
)
parser.add_argument(
"--batch-size",
type=int,
default=1000,
help=("Nombre de lignes insérées par batch. Défaut : 1000."),
)
parser.add_argument(
"--dry-run",
action="store_true",
help=("Valide les données sans écrire en base."),
)
return parser.parse_args()
def main() -> None:
"""Point d'entrée CLI du pipeline."""
args = parse_args()
if args.batch_size <= 0:
raise ValueError("--batch-size doit être strictement supérieur à 0.")
# resolve() est volontairement exécuté ici,
# dans la partie synchrone du programme.
# Cela évite une opération filesystem bloquante
# à l'intérieur d'une fonction async.
storage_uri = args.csv.resolve().as_uri()
asyncio.run(
import_historical(
csv_path=args.csv,
metadata_path=args.metadata,
source_timezone=(args.source_timezone),
batch_size=args.batch_size,
dry_run=args.dry_run,
storage_uri=storage_uri,
)
)
if __name__ == "__main__":
main()
+21
View File
@@ -1,4 +1,5 @@
from collections.abc import Sequence
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -29,3 +30,23 @@ class ReadingRepository:
)
lecture: Reading | None = await self._session.scalar(requete)
return lecture
async def list_history(
self,
*,
start: datetime,
end: datetime,
site_id: str | None = None,
limit: int,
offset: int,
) -> Sequence[Reading]:
requete = (
select(Reading)
.where(Reading.timestamp >= start, Reading.timestamp < end)
.order_by(Reading.timestamp.desc(), Reading.reading_id.desc())
.limit(limit)
.offset(offset)
)
if site_id is not None:
requete = requete.where(Reading.site_id == site_id)
return (await self._session.scalars(requete)).all()
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime
from decimal import Decimal
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict
class ReadingSource(StrEnum):
CSV = "csv"
API_CURRENT = "api_current"
API_HISTORY = "api_history"
class ReadingDataQuality(StrEnum):
GOOD = "good"
PARTIAL = "partial"
DEGRADED = "degraded"
CRITICAL = "critical"
class ReadingResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
reading_id: int
site_id: str
timestamp: datetime
source: ReadingSource
consumption_kw: float | None
consumption_kwh: float | None
# Piège : `Decimal` (miroir de `Numeric(14, 2)` en base, pour ne pas arrondir un montant)
# sérialise en chaîne dans le JSON, pas en nombre — un consommateur qui ferait un `parseFloat`
# naïf perdrait la précision que ce choix visait à garder.
consumption_euros: Decimal | None
voltage_v: float | None
current_a: float | None
power_factor: float | None
temperature_celsius: float | None
humidity_percent: float | None
solar_irradiance_wm2: float | None
is_working_hours: bool | None
data_quality: ReadingDataQuality | None
null_reasons: list[str] | None
imputed_values: dict[str, Any] | None
imputation_method: str | None
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class SensorDiagnosticResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
status: Literal["ok", "failing"]
since: datetime | None = Field(
description=(
"Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la "
"panne : l'historique ne permet pas de le dater sans requête supplémentaire."
)
)
class SiteSensorsResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
consumption: SensorDiagnosticResponse
electrical: SensorDiagnosticResponse
temperature: SensorDiagnosticResponse
humidity: SensorDiagnosticResponse
network: SensorDiagnosticResponse
class SiteSensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
site_id: str
site_name: str
sensors: SiteSensorsResponse
overall: Literal["ok", "degraded", "critical"]
class SensorStatusResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
timestamp: datetime
sites: list[SiteSensorStatusResponse]
+59
View File
@@ -0,0 +1,59 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from app.models.energy import Reading
from app.repositories.reading import ReadingRepository
FENETRE_PAR_DEFAUT = timedelta(hours=24)
FENETRE_MAXIMALE = timedelta(days=90)
class FenetreInverseeError(Exception):
"""`start` est postérieur ou égal à `end`."""
class FenetreTropLargeError(Exception):
"""L'écart entre `start` et `end` dépasse `FENETRE_MAXIMALE`."""
class ReadingService:
def __init__(self, *, readings: ReadingRepository) -> None:
self._readings = readings
async def list_history(
self,
*,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int,
offset: int,
) -> Sequence[Reading]:
debut, fin = self._resoudre_fenetre(start, end)
return await self._readings.list_history(
site_id=site_id, start=debut, end=fin, limit=limit, offset=offset
)
@staticmethod
def _resoudre_fenetre(
start: datetime | None, end: datetime | None
) -> tuple[datetime, datetime]:
# Piège : un datetime naïf (sans fuseau dans la chaîne ISO reçue) fait échouer la
# comparaison à `reading.timestamp` (`timestamptz`) au niveau du pilote, en 500 plutôt
# qu'un refus propre. On le traite comme de l'UTC plutôt que de le rejeter.
debut = _vers_utc(start)
fin = _vers_utc(end) or datetime.now(UTC)
if debut is None:
debut = fin - FENETRE_PAR_DEFAUT
if debut >= fin:
raise FenetreInverseeError
if fin - debut > FENETRE_MAXIMALE:
raise FenetreTropLargeError
return debut, fin
def _vers_utc(instant: datetime | None) -> datetime | None:
if instant is None:
return None
return instant if instant.tzinfo is not None else instant.replace(tzinfo=UTC)
+137
View File
@@ -0,0 +1,137 @@
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
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",
"temperature_sensor_failure": "temperature",
"humidity_sensor_failure": "humidity",
"network_loss": "network",
}
CHAMPS_PAR_CAPTEUR: dict[str, tuple[str, ...]] = {
"consumption": ("consumption_kw",),
"electrical": ("voltage_v", "current_a", "power_factor"),
"temperature": ("temperature_celsius",),
"humidity": ("humidity_percent",),
}
@dataclass(frozen=True, slots=True)
class DiagnosticCapteur:
status: CapteurStatus
since: datetime | None
@dataclass(frozen=True, slots=True)
class SanteCapteurs:
consumption: DiagnosticCapteur
electrical: DiagnosticCapteur
temperature: DiagnosticCapteur
humidity: DiagnosticCapteur
network: DiagnosticCapteur
@dataclass(frozen=True, slots=True)
class SanteSite:
site_id: str
site_name: str
sensors: SanteCapteurs
overall: OverallStatus
@dataclass(frozen=True, slots=True)
class EtatCapteurs:
timestamp: datetime
sites: list[SanteSite]
class SensorService:
def __init__(self, sites: SiteRepository, readings: ReadingRepository) -> None:
self._sites = sites
self._readings = readings
async def status(self) -> EtatCapteurs:
sites = await self._sites.list_all()
dernieres = {lecture.site_id: lecture for lecture in await self._readings.latest_by_site()}
return EtatCapteurs(
timestamp=datetime.now(UTC),
sites=[_sante_site(site, dernieres.get(site.site_id)) for site in sites],
)
def _sante_site(site: Site, derniere: Reading | None) -> SanteSite:
if derniere is None:
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=None),
overall="critical",
)
qualite = derniere.data_quality if derniere.data_quality in QUALITES_CONNUES else "critical"
overall = _overall_depuis_qualite(qualite)
if overall == "critical":
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=_tout_en_echec(since=derniere.timestamp),
overall="critical",
)
raisons_signalees = {
RAISON_VERS_CAPTEUR[raison]
for raison in (derniere.null_reasons or [])
if raison in RAISON_VERS_CAPTEUR
}
return SanteSite(
site_id=site.site_id,
site_name=site.site_name,
sensors=SanteCapteurs(
consumption=_diagnostic("consumption", derniere, raisons_signalees),
electrical=_diagnostic("electrical", derniere, raisons_signalees),
temperature=_diagnostic("temperature", derniere, raisons_signalees),
humidity=_diagnostic("humidity", derniere, raisons_signalees),
network=_diagnostic("network", derniere, raisons_signalees),
),
overall=overall,
)
def _overall_depuis_qualite(qualite: str) -> OverallStatus:
if qualite == "good":
return "ok"
if qualite in ("partial", "degraded"):
return "degraded"
return "critical"
def _diagnostic(capteur: str, derniere: Reading, raisons_signalees: set[str]) -> DiagnosticCapteur:
champs = CHAMPS_PAR_CAPTEUR.get(capteur, ())
en_echec = capteur in raisons_signalees or any(
getattr(derniere, champ) is None for champ in champs
)
return DiagnosticCapteur(
status="failing" if en_echec else "ok",
since=derniere.timestamp if en_echec else None,
)
def _tout_en_echec(since: datetime | None) -> SanteCapteurs:
echec = DiagnosticCapteur(status="failing", since=since)
return SanteCapteurs(
consumption=echec, electrical=echec, temperature=echec, humidity=echec, network=echec
)
+551
View File
@@ -1314,6 +1314,217 @@
}
]
}
},
"/api/v1/readings": {
"get": {
"tags": [
"readings"
],
"summary": "Liste l'historique des lectures",
"operationId": "list_readings_api_v1_readings_get",
"security": [
{
"Jeton d'accès": []
}
],
"parameters": [
{
"name": "site_id",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Site Id"
}
},
{
"name": "start",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Start"
}
},
{
"name": "end",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "End"
}
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 2000,
"minimum": 1,
"default": 500,
"title": "Limit"
}
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0,
"title": "Offset"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReadingResponse"
},
"title": "Response List Readings Api V1 Readings Get"
}
}
}
},
"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"
}
}
}
},
"400": {
"description": "Fenêtre temporelle invalide : `start` postérieur ou égal à `end`, ou écart entre les deux supérieur à 90 jours.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/api/v1/sensors/status": {
"get": {
"tags": [
"sensors"
],
"summary": "État de santé des capteurs par site",
"operationId": "get_status_api_v1_sensors_status_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SensorStatusResponse"
}
}
}
},
"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": "Droits insuffisants, ou mot de passe provisoire à changer quand `detail` vaut `password_change_required`.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
},
"security": [
{
"Jeton d'accès": []
}
]
}
}
},
"components": {
@@ -1611,6 +1822,225 @@
],
"title": "ReadinessStatus"
},
"ReadingDataQuality": {
"type": "string",
"enum": [
"good",
"partial",
"degraded",
"critical"
],
"title": "ReadingDataQuality"
},
"ReadingResponse": {
"properties": {
"reading_id": {
"type": "integer",
"title": "Reading Id"
},
"site_id": {
"type": "string",
"title": "Site Id"
},
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"source": {
"$ref": "#/components/schemas/ReadingSource"
},
"consumption_kw": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kw"
},
"consumption_kwh": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Consumption Kwh"
},
"consumption_euros": {
"anyOf": [
{
"type": "string",
"pattern": "^(?!^[-+.]*$)[+-]?0*\\d*\\.?\\d*$"
},
{
"type": "null"
}
],
"title": "Consumption Euros"
},
"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"
},
"solar_irradiance_wm2": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"title": "Solar Irradiance Wm2"
},
"is_working_hours": {
"anyOf": [
{
"type": "boolean"
},
{
"type": "null"
}
],
"title": "Is Working Hours"
},
"data_quality": {
"anyOf": [
{
"$ref": "#/components/schemas/ReadingDataQuality"
},
{
"type": "null"
}
]
},
"null_reasons": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Null Reasons"
},
"imputed_values": {
"anyOf": [
{
"additionalProperties": true,
"type": "object"
},
{
"type": "null"
}
],
"title": "Imputed Values"
},
"imputation_method": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Imputation Method"
}
},
"type": "object",
"required": [
"reading_id",
"site_id",
"timestamp",
"source",
"consumption_kw",
"consumption_kwh",
"consumption_euros",
"voltage_v",
"current_a",
"power_factor",
"temperature_celsius",
"humidity_percent",
"solar_irradiance_wm2",
"is_working_hours",
"data_quality",
"null_reasons",
"imputed_values",
"imputation_method"
],
"title": "ReadingResponse"
},
"ReadingSource": {
"type": "string",
"enum": [
"csv",
"api_current",
"api_history"
],
"title": "ReadingSource"
},
"RecommendationResponse": {
"properties": {
"recommendation_id": {
@@ -1659,6 +2089,59 @@
],
"title": "Role"
},
"SensorDiagnosticResponse": {
"properties": {
"status": {
"type": "string",
"enum": [
"ok",
"failing"
],
"title": "Status"
},
"since": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Since",
"description": "Horodatage de la dernière lecture reçue pour ce site. Ce n'est pas le début de la panne : l'historique ne permet pas de le dater sans requête supplémentaire."
}
},
"type": "object",
"required": [
"status",
"since"
],
"title": "SensorDiagnosticResponse"
},
"SensorStatusResponse": {
"properties": {
"timestamp": {
"type": "string",
"format": "date-time",
"title": "Timestamp"
},
"sites": {
"items": {
"$ref": "#/components/schemas/SiteSensorStatusResponse"
},
"type": "array",
"title": "Sites"
}
},
"type": "object",
"required": [
"timestamp",
"sites"
],
"title": "SensorStatusResponse"
},
"SiteCurrentResponse": {
"properties": {
"timestamp": {
@@ -1852,6 +2335,66 @@
],
"title": "SiteResponse"
},
"SiteSensorStatusResponse": {
"properties": {
"site_id": {
"type": "string",
"title": "Site Id"
},
"site_name": {
"type": "string",
"title": "Site Name"
},
"sensors": {
"$ref": "#/components/schemas/SiteSensorsResponse"
},
"overall": {
"type": "string",
"enum": [
"ok",
"degraded",
"critical"
],
"title": "Overall"
}
},
"type": "object",
"required": [
"site_id",
"site_name",
"sensors",
"overall"
],
"title": "SiteSensorStatusResponse"
},
"SiteSensorsResponse": {
"properties": {
"consumption": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"electrical": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"temperature": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"humidity": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
},
"network": {
"$ref": "#/components/schemas/SensorDiagnosticResponse"
}
},
"type": "object",
"required": [
"consumption",
"electrical",
"temperature",
"humidity",
"network"
],
"title": "SiteSensorsResponse"
},
"SiteSummaryResponse": {
"properties": {
"site_id": {
@@ -2180,6 +2723,14 @@
{
"name": "stats",
"description": "Statistiques agrégées de consommation. Accessible à partir du rôle `lecteur`."
},
{
"name": "readings",
"description": "Historique des lectures de consommation. Fenêtre temporelle plafonnée à 90 jours, 24 dernières heures par défaut si `start`/`end` sont omis. Accessible à partir du rôle `lecteur`."
},
{
"name": "sensors",
"description": "État de santé des capteurs par site. Réservé au rôle `admin`."
}
]
}
+2
View File
@@ -16,6 +16,7 @@ dependencies = [
"pyjwt>=2.10",
"argon2-cffi>=23.1",
"anyio>=4.0",
"pandas>=3.0.5",
]
[dependency-groups]
@@ -26,6 +27,7 @@ dev = [
"pytest-asyncio>=1.4.0",
"pytest-cov>=7.1.0",
"httpx>=0.28.1",
"pandas-stubs>=3.0.5.260914",
]
[build-system]
+2
View File
@@ -36,6 +36,8 @@ ROUTES_A_ROLE = {
("GET", "/api/v1/recommendations"),
("GET", "/api/v1/recommendations/{recommendation_id}"),
("GET", "/api/v1/stats/summary"),
("GET", "/api/v1/readings"),
("GET", "/api/v1/sensors/status"),
}
+198
View File
@@ -0,0 +1,198 @@
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_current_principal, get_reading_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.models.energy import Reading
from app.services.reading import FenetreInverseeError, FenetreTropLargeError
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 reading(reading_id: int = 1, site_id: str = "site-1") -> Reading:
return Reading(
reading_id=reading_id,
site_id=site_id,
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
source="api_current",
consumption_kw=42.5,
consumption_kwh=None,
consumption_euros=None,
voltage_v=230.0,
current_a=None,
power_factor=None,
temperature_celsius=None,
humidity_percent=None,
solar_irradiance_wm2=None,
is_working_hours=True,
data_quality="good",
null_reasons=None,
imputed_values=None,
imputation_method=None,
raw_data={},
)
class FauxService:
def __init__(self, leve: Exception | None = None) -> None:
self.reading = reading()
self.leve = leve
self.appels: list[tuple[str | None, str | None, str | None, int, int]] = []
async def list_history(
self,
*,
site_id: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
limit: int,
offset: int,
) -> list[Reading]:
self.appels.append((site_id, start, end, limit, offset))
if self.leve is not None:
raise self.leve
return [self.reading]
@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(*, leve: Exception | None = None) -> FauxService:
service = FauxService(leve=leve)
app.dependency_overrides[get_reading_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_reading_service, None)
async def test_list_readings_returns_the_readings(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings")
assert response.status_code == 200
corps = response.json()
assert corps == [
{
"reading_id": 1,
"site_id": "site-1",
"timestamp": "2026-09-16T00:00:00Z",
"source": "api_current",
"consumption_kw": 42.5,
"consumption_kwh": None,
"consumption_euros": None,
"voltage_v": 230.0,
"current_a": None,
"power_factor": None,
"temperature_celsius": None,
"humidity_percent": None,
"solar_irradiance_wm2": None,
"is_working_hours": True,
"data_quality": "good",
"null_reasons": None,
"imputed_values": None,
"imputation_method": None,
}
]
async def test_list_readings_transmits_the_filters_and_pagination(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
service = servi()
response = await client.get(
"/api/v1/readings",
params={
"site_id": "site-1",
"start": "2026-09-01T00:00:00Z",
"end": "2026-09-02T00:00:00Z",
"limit": 50,
"offset": 10,
},
)
assert response.status_code == 200
assert service.appels == [
(
"site-1",
datetime(2026, 9, 1, tzinfo=UTC),
datetime(2026, 9, 2, tzinfo=UTC),
50,
10,
)
]
async def test_list_readings_returns_400_when_the_window_is_inverted(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(leve=FenetreInverseeError())
response = await client.get("/api/v1/readings")
assert response.status_code == 400
async def test_list_readings_returns_400_when_the_window_is_too_large(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi(leve=FenetreTropLargeError())
response = await client.get("/api/v1/readings")
assert response.status_code == 400
async def test_list_readings_returns_422_for_a_limit_above_the_maximum(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings", params={"limit": 5000})
assert response.status_code == 422
async def test_list_readings_returns_422_for_a_negative_offset(
servi: Callable[..., FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/readings", params={"offset": -1})
assert response.status_code == 422
async def test_list_readings_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/readings")
assert response.status_code == 200
assert response.json() == []
+91
View File
@@ -0,0 +1,91 @@
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_current_principal, get_sensor_service
from app.core.principal import Principal
from app.core.roles import AccountKind, Role
from app.services.sensor import DiagnosticCapteur, EtatCapteurs, SanteCapteurs, SanteSite
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
def principal(role: Role = Role.ADMIN) -> Principal:
return Principal(
id=uuid4(),
email=f"{role.value}@enervision.fr",
role=role,
kind=AccountKind.HUMAIN,
must_change_password=False,
)
class FauxService:
def __init__(self) -> None:
ok = DiagnosticCapteur(status="ok", since=None)
en_echec = DiagnosticCapteur(status="failing", since=TIMESTAMP)
self.etat = EtatCapteurs(
timestamp=TIMESTAMP,
sites=[
SanteSite(
site_id="SITE001",
site_name="Bureau Paris La Défense",
sensors=SanteCapteurs(
consumption=ok,
electrical=ok,
temperature=en_echec,
humidity=ok,
network=ok,
),
overall="degraded",
)
],
)
async def status(self) -> EtatCapteurs:
return self.etat
@pytest.fixture
def admin_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, admin_connecte: None) -> Iterator[Callable[[], FauxService]]:
def installe() -> FauxService:
service = FauxService()
app.dependency_overrides[get_sensor_service] = lambda: service
return service
yield installe
app.dependency_overrides.pop(get_sensor_service, None)
async def test_get_status_returns_the_service_result(
servi: Callable[[], FauxService], client: AsyncClient
) -> None:
servi()
response = await client.get("/api/v1/sensors/status")
assert response.status_code == 200
corps = response.json()
assert corps["sites"][0]["site_id"] == "SITE001"
assert corps["sites"][0]["overall"] == "degraded"
assert corps["sites"][0]["sensors"]["temperature"]["status"] == "failing"
assert corps["sites"][0]["sensors"]["consumption"]["status"] == "ok"
async def test_get_status_refuses_a_reader(app: FastAPI, client: AsyncClient) -> None:
app.dependency_overrides[get_current_principal] = lambda: principal(Role.LECTEUR)
response = await client.get("/api/v1/sensors/status")
assert response.status_code == 403
@@ -0,0 +1,239 @@
import hashlib
import json
import pandas as pd
import pytest
from app.etl.historical_import import (
SOURCE_NAME,
build_reading_batch,
classify_quality,
compute_sha256,
load_metadata,
normalize_timestamps,
validate_source,
)
def make_metadata() -> dict:
return {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
def make_dataframe() -> pd.DataFrame:
return pd.DataFrame(
[
{
"timestamp": "2023-01-01 00:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 10.5,
"consumption_euros": 2.5,
"temperature_celsius": 20.0,
"humidity_percent": 50.0,
"solar_irradiance_wm2": 0.0,
"hour": 0,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
{
"timestamp": "2023-01-01 01:00:00",
"site_id": "SITE001",
"site_type": "office",
"site_name": "Site 1",
"consumption_kwh": 11.0,
"consumption_euros": 2.7,
"temperature_celsius": 19.5,
"humidity_percent": 52.0,
"solar_irradiance_wm2": 0.0,
"hour": 1,
"day_of_week": 6,
"day_name": "Sunday",
"month": 1,
"is_weekend": True,
"is_working_hours": False,
},
]
)
def test_compute_sha256(tmp_path):
file_path = tmp_path / "dataset.csv"
content = b"hello-enervision"
file_path.write_bytes(content)
expected = hashlib.sha256(content).hexdigest()
assert compute_sha256(file_path) == expected
def test_load_metadata(tmp_path):
metadata_path = tmp_path / "metadata.json"
metadata = {
"total_records": 2,
"sites": {
"SITE001": {},
},
}
metadata_path.write_text(
json.dumps(metadata),
encoding="utf-8",
)
assert load_metadata(metadata_path) == metadata
def test_validate_source_accepts_valid_dataset():
frame = make_dataframe()
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_missing_column():
frame = make_dataframe().drop(columns=["consumption_kwh"])
with pytest.raises(
ValueError,
match="Colonnes obligatoires absentes",
):
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_duplicates():
frame = make_dataframe()
frame.loc[1, "timestamp"] = frame.loc[
0,
"timestamp",
]
with pytest.raises(
ValueError,
match="doublons",
):
validate_source(
frame,
make_metadata(),
)
def test_validate_source_rejects_unknown_site():
frame = make_dataframe()
frame.loc[1, "site_id"] = "SITE999"
with pytest.raises(
ValueError,
match="Sites incohérents",
):
validate_source(
frame,
make_metadata(),
)
def test_normalize_timestamps_adds_timezone():
frame = make_dataframe()
normalized = normalize_timestamps(
frame,
"UTC",
)
assert normalized["timestamp"].dt.tz is not None
assert "_source_timestamp" in normalized.columns
def test_classify_quality_good():
row = make_dataframe().iloc[0].to_dict()
quality, reasons = classify_quality(row)
assert quality == "good"
assert reasons == []
def test_classify_quality_degraded_when_consumption_missing():
row = make_dataframe().iloc[0].to_dict()
row["consumption_kwh"] = None
quality, reasons = classify_quality(row)
assert quality == "degraded"
assert "missing:consumption_kwh" in reasons
def test_build_reading_batch_respects_database_contract():
frame = normalize_timestamps(
make_dataframe(),
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
assert len(rows) == 1
row = rows[0]
assert row["dataset_id"] == 3
# Important :
# contrainte ck_reading_dataset_source.
assert row["source"] == "csv"
assert SOURCE_NAME == "csv"
# Important :
# contrainte ck_reading_imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
assert row["data_quality"] == "good"
assert row["null_reasons"] == []
def test_build_reading_batch_keeps_missing_values():
frame = make_dataframe()
frame.loc[0, "temperature_celsius"] = None
frame = normalize_timestamps(
frame,
"UTC",
)
rows = build_reading_batch(
frame.iloc[:1],
dataset_id=3,
)
row = rows[0]
assert row["temperature_celsius"] is None
assert "missing:temperature_celsius" in row["null_reasons"]
# RAW ingestion : aucune imputation.
assert row["imputed_values"] is None
assert row["imputation_method"] is None
@@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models.energy import Reading, Site
from app.repositories.reading import ReadingRepository
from tests.repositories.test_site import creer as creer_site
from tests.repositories.test_site import identifiant as identifiant_site
pytestmark = pytest.mark.integration
@@ -25,6 +27,20 @@ def lecture(site_id: str, *, timestamp: datetime, consumption_kw: float) -> Read
)
async def creer_lecture(session: AsyncSession, *, site_id: str, **overrides: object) -> Reading:
reading = Reading(
site_id=site_id,
timestamp=overrides.get("timestamp", datetime(2026, 9, 16, tzinfo=UTC)),
source=overrides.get("source", "api_current"),
consumption_kw=overrides.get("consumption_kw", 10.0),
data_quality=overrides.get("data_quality", "good"),
raw_data=overrides.get("raw_data", {}),
)
session.add(reading)
await session.flush()
return reading
async def test_latest_by_site_keeps_only_the_most_recent_reading(session: AsyncSession) -> None:
site_id = identifiant()
maintenant = datetime.now(UTC)
@@ -70,3 +86,110 @@ async def test_latest_by_site_returns_one_row_per_site(session: AsyncSession) ->
await session.rollback()
assert identifiants == {premier, second}
async def test_list_history_orders_the_readings_by_timestamp_descending(
session: AsyncSession,
) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
ancienne = 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)
)
resultats = await depot.list_history(
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [
r.reading_id for r in resultats if r.reading_id in (ancienne.reading_id, recente.reading_id)
]
await session.rollback()
assert identifiants == [recente.reading_id, ancienne.reading_id]
async def test_list_history_filters_by_site_id(session: AsyncSession) -> None:
premier = await creer_site(session)
second = await creer_site(session)
depot = ReadingRepository(session)
voulue = await creer_lecture(session, site_id=premier.site_id)
await creer_lecture(session, site_id=second.site_id)
resultats = await depot.list_history(
site_id=premier.site_id,
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [voulue.reading_id]
async def test_list_history_excludes_readings_outside_the_window(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
dedans = await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, 10, tzinfo=UTC)
)
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 8, 1, tzinfo=UTC))
await creer_lecture(session, site_id=site.site_id, timestamp=datetime(2026, 10, 1, tzinfo=UTC))
resultats = await depot.list_history(
site_id=site.site_id,
start=datetime(2026, 9, 1, tzinfo=UTC),
end=datetime(2026, 9, 30, tzinfo=UTC),
limit=100,
offset=0,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [dedans.reading_id]
async def test_list_history_respects_limit_and_offset(session: AsyncSession) -> None:
site = await creer_site(session)
depot = ReadingRepository(session)
lectures = [
await creer_lecture(
session, site_id=site.site_id, timestamp=datetime(2026, 9, jour, tzinfo=UTC)
)
for jour in (1, 2, 3)
]
resultats = await depot.list_history(
site_id=site.site_id,
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=1,
offset=1,
)
identifiants = [r.reading_id for r in resultats]
await session.rollback()
assert identifiants == [lectures[1].reading_id]
async def test_list_history_returns_an_empty_list_when_there_is_nothing(
session: AsyncSession,
) -> None:
depot = ReadingRepository(session)
resultats = await depot.list_history(
site_id=identifiant_site(),
start=datetime(2026, 8, 1, tzinfo=UTC),
end=datetime(2026, 10, 1, tzinfo=UTC),
limit=100,
offset=0,
)
assert list(resultats) == []
+153
View File
@@ -0,0 +1,153 @@
from datetime import UTC, datetime, timedelta
import pytest
from app.models.energy import Reading
from app.services.reading import (
FENETRE_MAXIMALE,
FENETRE_PAR_DEFAUT,
FenetreInverseeError,
FenetreTropLargeError,
ReadingService,
)
def reading(reading_id: int = 1, site_id: str = "site-1") -> Reading:
return Reading(
reading_id=reading_id,
site_id=site_id,
timestamp=datetime(2026, 9, 16, tzinfo=UTC),
source="api_current",
consumption_kw=10.0,
data_quality="good",
raw_data={},
)
class FakeRepository:
def __init__(self, readings: list[Reading]) -> None:
self._readings = readings
self.appels: list[tuple[str | None, datetime, datetime, int, int]] = []
async def list_history(
self,
*,
start: datetime,
end: datetime,
site_id: str | None = None,
limit: int,
offset: int,
) -> list[Reading]:
self.appels.append((site_id, start, end, limit, offset))
return self._readings
async def test_list_history_returns_the_repository_readings() -> None:
service = ReadingService(readings=FakeRepository([reading(1), reading(2)]))
lectures = await service.list_history(limit=500, offset=0)
assert [r.reading_id for r in lectures] == [1, 2]
async def test_list_history_relays_the_site_id_limit_and_offset() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime(2026, 9, 1, tzinfo=UTC)
fin = datetime(2026, 9, 2, tzinfo=UTC)
await service.list_history(site_id="site-1", start=debut, end=fin, limit=50, offset=10)
assert depot.appels == [("site-1", debut, fin, 50, 10)]
async def test_list_history_defaults_to_the_last_24_hours_when_no_window_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
avant = datetime.now(UTC)
await service.list_history(limit=500, offset=0)
apres = datetime.now(UTC)
_, debut, fin, _, _ = depot.appels[0]
assert avant <= fin <= apres
assert fin - debut == FENETRE_PAR_DEFAUT
async def test_list_history_defaults_end_to_now_when_only_start_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime.now(UTC) - timedelta(hours=1)
avant = datetime.now(UTC)
await service.list_history(start=debut, limit=500, offset=0)
apres = datetime.now(UTC)
_, debut_transmis, fin, _, _ = depot.appels[0]
assert debut_transmis == debut
assert avant <= fin <= apres
async def test_list_history_defaults_start_to_24_hours_before_end_when_only_end_is_given() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
fin = datetime(2026, 9, 16, tzinfo=UTC)
await service.list_history(end=fin, limit=500, offset=0)
_, debut, fin_transmise, _, _ = depot.appels[0]
assert fin_transmise == fin
assert debut == fin - FENETRE_PAR_DEFAUT
async def test_list_history_normalizes_naive_datetimes_to_utc() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
await service.list_history(
start=datetime(2026, 9, 1), end=datetime(2026, 9, 2), limit=500, offset=0
)
_, debut, fin, _, _ = depot.appels[0]
assert debut == datetime(2026, 9, 1, tzinfo=UTC)
assert fin == datetime(2026, 9, 2, tzinfo=UTC)
async def test_list_history_raises_when_start_is_after_end() -> None:
service = ReadingService(readings=FakeRepository([]))
with pytest.raises(FenetreInverseeError):
await service.list_history(
start=datetime(2026, 9, 2, tzinfo=UTC),
end=datetime(2026, 9, 1, tzinfo=UTC),
limit=500,
offset=0,
)
async def test_list_history_raises_when_start_equals_end() -> None:
service = ReadingService(readings=FakeRepository([]))
instant = datetime(2026, 9, 1, tzinfo=UTC)
with pytest.raises(FenetreInverseeError):
await service.list_history(start=instant, end=instant, limit=500, offset=0)
async def test_list_history_raises_when_the_window_exceeds_the_maximum_span() -> None:
service = ReadingService(readings=FakeRepository([]))
debut = datetime(2026, 1, 1, tzinfo=UTC)
fin = debut + FENETRE_MAXIMALE + timedelta(seconds=1)
with pytest.raises(FenetreTropLargeError):
await service.list_history(start=debut, end=fin, limit=500, offset=0)
async def test_list_history_accepts_a_window_exactly_at_the_maximum_span() -> None:
depot = FakeRepository([])
service = ReadingService(readings=depot)
debut = datetime(2026, 1, 1, tzinfo=UTC)
fin = debut + FENETRE_MAXIMALE
await service.list_history(start=debut, end=fin, limit=500, offset=0)
assert depot.appels == [(None, debut, fin, 500, 0)]
+224
View File
@@ -0,0 +1,224 @@
from dataclasses import dataclass, field
from datetime import UTC, datetime
from app.services.sensor import SensorService
TIMESTAMP = datetime(2026, 9, 16, 12, 0, tzinfo=UTC)
@dataclass
class FauxSite:
site_id: str
site_name: str
@dataclass
class FauxLecture:
site_id: str
timestamp: datetime
data_quality: str | None
null_reasons: list[str] | None = field(default_factory=list)
consumption_kw: float | None = 10.0
voltage_v: float | None = 230.0
current_a: float | None = 5.0
power_factor: float | None = 0.95
temperature_celsius: float | None = 21.0
humidity_percent: float | None = 40.0
class FauxDepotSites:
def __init__(self, sites: list[FauxSite]) -> None:
self._sites = sites
async def list_all(self) -> list[FauxSite]:
return self._sites
class FauxDepotLectures:
def __init__(self, lectures: list[FauxLecture]) -> None:
self._lectures = lectures
async def latest_by_site(self) -> list[FauxLecture]:
return self._lectures
async def test_status_marks_a_site_without_any_reading_as_critical_with_every_sensor_failing() -> (
None
):
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "critical"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "failing"
assert capteur.since is None
async def test_status_marks_every_sensor_ok_on_a_good_quality_reading_with_no_null_field() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "good")]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "ok"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "ok"
assert capteur.since is None
async def test_status_flags_the_sensor_named_in_null_reasons() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture(
"A",
TIMESTAMP,
"partial",
null_reasons=["temperature_sensor_failure"],
temperature_celsius=None,
)
]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "degraded"
assert site.sensors.temperature.status == "failing"
assert site.sensors.temperature.since == TIMESTAMP
assert site.sensors.consumption.status == "ok"
assert site.sensors.electrical.status == "ok"
assert site.sensors.humidity.status == "ok"
assert site.sensors.network.status == "ok"
async def test_status_flags_a_sensor_from_a_null_field_even_without_a_null_reason() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], humidity_percent=None)]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.sensors.humidity.status == "failing"
assert site.sensors.humidity.since == TIMESTAMP
async def test_status_flags_electrical_as_failing_when_any_of_its_three_fields_is_null() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "partial", null_reasons=[], power_factor=None)]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.sensors.electrical.status == "failing"
async def test_status_forces_every_sensor_to_failing_when_overall_is_critical() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, "critical", null_reasons=[])]), # type: ignore[arg-type]
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "critical"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "failing"
assert capteur.since == TIMESTAMP
async def test_status_treats_an_unknown_data_quality_as_critical() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures([FauxLecture("A", TIMESTAMP, None, null_reasons=[])]), # type: ignore[arg-type]
)
etat = await service.status()
assert etat.sites[0].overall == "critical"
async def test_status_ignores_an_unknown_null_reason() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[FauxLecture("A", TIMESTAMP, "good", null_reasons=["something_else"])]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "ok"
for capteur in (
site.sensors.consumption,
site.sensors.electrical,
site.sensors.temperature,
site.sensors.humidity,
site.sensors.network,
):
assert capteur.status == "ok"
async def test_status_flags_network_from_null_reasons_only() -> None:
service = SensorService(
sites=FauxDepotSites([FauxSite("A", "Site A")]), # type: ignore[arg-type]
readings=FauxDepotLectures( # type: ignore[arg-type]
[
FauxLecture(
"A",
TIMESTAMP,
"partial",
null_reasons=["network_loss"],
)
]
),
)
etat = await service.status()
site = etat.sites[0]
assert site.overall == "degraded"
assert site.sensors.network.status == "failing"
assert site.sensors.network.since == TIMESTAMP
assert site.sensors.consumption.status == "ok"
assert site.sensors.electrical.status == "ok"
assert site.sensors.temperature.status == "ok"
assert site.sensors.humidity.status == "ok"
+109
View File
@@ -1,6 +1,11 @@
version = 1
revision = 3
requires-python = "==3.14.*"
resolution-markers = [
"sys_platform == 'win32'",
"sys_platform == 'emscripten'",
"sys_platform != 'emscripten' and sys_platform != 'win32'",
]
[[package]]
name = "alembic"
@@ -311,6 +316,7 @@ dependencies = [
{ name = "argon2-cffi" },
{ name = "asyncpg" },
{ name = "fastapi" },
{ name = "pandas" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
@@ -324,6 +330,7 @@ dependencies = [
dev = [
{ name = "httpx" },
{ name = "mypy" },
{ name = "pandas-stubs" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
@@ -337,6 +344,7 @@ requires-dist = [
{ name = "argon2-cffi", specifier = ">=23.1" },
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "fastapi", specifier = ">=0.141.1" },
{ name = "pandas", specifier = ">=3.0.5" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=8.1.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.13.5" },
{ name = "pydantic-settings", specifier = ">=2.15.0" },
@@ -350,6 +358,7 @@ requires-dist = [
dev = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "mypy", specifier = ">=2.3.1" },
{ name = "pandas-stubs", specifier = ">=3.0.5.260914" },
{ name = "pytest", specifier = ">=9.1.1" },
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
{ name = "pytest-cov", specifier = ">=7.1.0" },
@@ -595,6 +604,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "numpy"
version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" },
{ url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" },
{ url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" },
{ url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" },
{ url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" },
{ url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" },
{ url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" },
{ url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" },
{ url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" },
{ url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" },
{ url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" },
{ url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" },
{ url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" },
{ url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" },
{ url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" },
]
[[package]]
name = "packaging"
version = "26.3"
@@ -604,6 +642,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pandas"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" },
{ url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" },
{ url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" },
{ url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" },
{ url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" },
{ url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" },
{ url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" },
{ url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" },
{ url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" },
{ url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" },
{ url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" },
{ url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" },
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
]
[[package]]
name = "pandas-stubs"
version = "3.0.5.260914"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/93/8948ae6c1e1e3d6833596fd266f7be2d27c1451b8be094975ad42c5e842e/pandas_stubs-3.0.5.260914.tar.gz", hash = "sha256:3f6fc1f147f68fd89c007105e7c94a948acb4ecd7eb20dc1c02e153c4ed5c250", size = 117622, upload-time = "2026-09-14T16:42:35.065Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/cb/5ad79e02a556cc23fed5816de0109fa8af660c66cfa5f4af74c3e8d4cd26/pandas_stubs-3.0.5.260914-py3-none-any.whl", hash = "sha256:39a1300c5c5c55fdf609e3476805decce5d5015539a4dcb683449f8feaeee2fb", size = 177344, upload-time = "2026-09-14T16:42:33.771Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
@@ -788,6 +867,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.3"
@@ -857,6 +948,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.52"
@@ -916,6 +1016,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
]
[[package]]
name = "uvicorn"
version = "0.53.0"
+47
View File
@@ -0,0 +1,47 @@
# ==================
# Étape 1 : Build
# ==================
# Image pour frontend
FROM node:24-alpine3.22 AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
# Installation des dépendances du projet avec npm
RUN npm ci
# Copie du code source vers le conteneur
COPY . .
# Build
RUN npm run build
# ==================
# Étape 2 : Runner
# ==================
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
# Copie de la configuration de nginx
COPY --chown=root:root --chmod=755 nginx.conf /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=root:root --chmod=755 --from=builder /app/dist/*/browser /usr/share/nginx/html
# Create necessary directories with proper permissions for nginx
RUN mkdir -p /var/log/nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html
# Use a non-root user for security best practices
USER nginx
# Frontend : port 3000
# Backend : port 8000
EXPOSE 3000
# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]
+1
View File
@@ -81,6 +81,7 @@
"builder": "@angular/build:unit-test",
"options": {
"coverage": true,
"isolate": true,
"coverageReporters": [
"text-summary",
"lcov",
+32
View File
@@ -0,0 +1,32 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
}
}
}
+10 -2
View File
@@ -1,13 +1,21 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import {ApplicationConfig, inject, provideAppInitializer, provideBrowserGlobalErrorListeners} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { mockApiInterceptor } from './core/interceptors/mock-api-interceptor';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import {catchError, firstValueFrom, of} from 'rxjs';
import {AuthService} from './core/services/auth.service';
import {authInterceptor} from './core/interceptors/auth-interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(withInterceptors([mockApiInterceptor])),
provideHttpClient(withInterceptors([authInterceptor, mockApiInterceptor])),
provideAppInitializer(() => {
const auth = inject(AuthService);
// Un 401 ici est normal : ça veut juste dire qu'il n'y a pas de session.
return firstValueFrom(auth.refreshShared().pipe(catchError(() => of(null))));
}),
],
};
+5 -1
View File
@@ -1,9 +1,13 @@
import { Routes } from '@angular/router';
import {authGuard} from './core/guards/auth-guard';
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'login', loadComponent: () => import('./features/auth/login/login').then(m => m.Login) },
{ path: 'change-password', loadComponent: () => import('./features/auth/change-password/change-password').then(m => m.ChangePassword) },
{
path: 'dashboard',
loadComponent: () => import('./features/dashboard/dashboard').then((m) => m.Dashboard),
canActivate: [authGuard],
loadComponent: () => import('./features/dashboard/dashboard').then(m => m.Dashboard),
},
];
@@ -0,0 +1,67 @@
import { TestBed } from '@angular/core/testing';
import { Router, ActivatedRouteSnapshot } from '@angular/router';
import { vi } from 'vitest';
import { authGuard } from './auth-guard';
import { AuthService } from '../services/auth.service';
describe('authGuard', () => {
let authMock: { isAuthenticated: ReturnType<typeof vi.fn>; principal: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(() => {
authMock = { isAuthenticated: vi.fn(), principal: vi.fn() };
routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
});
it('redirige vers /login si non authentifié', () => {
authMock.isAuthenticated.mockReturnValue(false);
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(false);
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('redirige vers /login si le rôle ne correspond pas', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'lecteur' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(false);
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('autorise si authentifié et rôle correspondant', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'admin' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: { role: 'admin' } } as unknown as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
it('autorise si authentifié et aucun rôle requis', () => {
authMock.isAuthenticated.mockReturnValue(true);
authMock.principal.mockReturnValue({ role: 'lecteur' });
const result = TestBed.runInInjectionContext(() =>
authGuard({ data: {} } as ActivatedRouteSnapshot, {} as any)
);
expect(result).toBe(true);
});
});
@@ -0,0 +1,21 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = (route) => {
const auth = inject(AuthService);
const router = inject(Router);
if (!auth.isAuthenticated()) {
router.navigate(['/login']);
return false;
}
const requiredRole = route.data['role'] as string | undefined;
if (requiredRole && auth.principal()?.role !== requiredRole) {
router.navigate(['/login']);
return false;
}
return true;
};
@@ -0,0 +1,161 @@
import { TestBed } from '@angular/core/testing';
import {
HttpClient,
HttpHandlerFn,
HttpHeaders,
HttpRequest,
provideHttpClient,
withInterceptors
} from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { authInterceptor } from './auth-interceptor';
import { AuthService } from '../services/auth.service';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let authMock: { getAccessToken: ReturnType<typeof vi.fn>; clearSession: ReturnType<typeof vi.fn>; refreshShared: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(() => {
authMock = {
getAccessToken: vi.fn().mockReturnValue('fake-token'),
clearSession: vi.fn(),
refreshShared: vi.fn(),
};
routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('ajoute le header Authorization quand un token est disponible', () => {
http.get('/api/v1/stats/summary').subscribe();
const req = httpMock.expectOne('/api/v1/stats/summary');
expect(req.request.headers.get('Authorization')).toBe('Bearer fake-token');
req.flush({});
});
it("n'ajoute pas le header Authorization sur /auth/login", () => {
http.post('/api/v1/auth/login', {}).subscribe();
const req = httpMock.expectOne('/api/v1/auth/login');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
it('ajoute withCredentials sur les routes /auth/*', () => {
http.post('/api/v1/auth/login', {}).subscribe();
const req = httpMock.expectOne('/api/v1/auth/login');
expect(req.request.withCredentials).toBe(true);
req.flush({});
});
it('redirige vers /change-password sur un 403 avec ce detail précis', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({ detail: 'password_change_required' }, { status: 403, statusText: 'Forbidden' });
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
});
it('ne redirige pas sur un 403 avec un autre detail', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({ detail: 'Droits insuffisants' }, { status: 403, statusText: 'Forbidden' });
expect(routerMock.navigate).not.toHaveBeenCalled();
});
it('déconnecte et redirige vers /login sur un 401 avec error="invalid_token"', () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush(
{},
{ status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="invalid_token"' }) }
);
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('déconnecte directement sur un 401 provenant de /auth/refresh, sans tenter de rafraîchir', () => {
http.post('/api/v1/auth/refresh', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/refresh');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('rafraîchit puis rejoue la requête sur un 401 avec error="expired"', () => {
authMock.refreshShared.mockReturnValue(of({ access_token: 'new-token' }));
authMock.getAccessToken.mockReturnValueOnce('old-token').mockReturnValue('new-token');
let result: unknown;
http.get('/api/v1/dashboard').subscribe((r) => (result = r));
const firstReq = httpMock.expectOne('/api/v1/dashboard');
firstReq.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) });
const retriedReq = httpMock.expectOne('/api/v1/dashboard');
expect(retriedReq.request.headers.get('Authorization')).toBe('Bearer new-token');
retriedReq.flush({ ok: true });
expect(result).toEqual({ ok: true });
});
it('déconnecte si le rafraîchissement échoue après un 401 "expired"', () => {
authMock.refreshShared.mockReturnValue(throwError(() => new Error('refresh failed')));
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush({}, { status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="expired"' }) });
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it("propage l'erreur telle quelle si ce n'est pas une HttpErrorResponse", () => {
const req = new HttpRequest('GET', '/api/v1/dashboard');
const boom = new Error('erreur inattendue, pas HTTP');
const next: HttpHandlerFn = () => throwError(() => boom);
let captured: unknown;
TestBed.runInInjectionContext(() => {
authInterceptor(req, next).subscribe({ error: (e) => (captured = e) });
});
expect(captured).toBe(boom);
});
it('propage un 401 sur /auth/login sans tenter de rafraîchir ni déconnecter', () => {
http.post('/api/v1/auth/login', {}).subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/auth/login');
req.flush({}, { status: 401, statusText: 'Unauthorized' });
expect(authMock.refreshShared).not.toHaveBeenCalled();
expect(authMock.clearSession).not.toHaveBeenCalled();
});
it("propage un 401 dont le WWW-Authenticate ne correspond à aucun cas connu", () => {
http.get('/api/v1/dashboard').subscribe({ error: () => {} });
const req = httpMock.expectOne('/api/v1/dashboard');
req.flush(
{},
{ status: 401, statusText: 'Unauthorized', headers: new HttpHeaders({ 'WWW-Authenticate': 'Bearer error="unknown_case"' }) }
);
expect(authMock.refreshShared).not.toHaveBeenCalled();
expect(authMock.clearSession).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, catchError, switchMap, throwError } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { TokenResponse } from '../../shared/models/auth.model';
function parseAuthError(response: HttpErrorResponse): string | null {
const header = response.headers?.get('WWW-Authenticate') ?? '';
const match = header.match(/error="([^"]+)"/);
return match ? match[1] : null;
}
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const isAuthRoute = req.url.includes('/auth/');
let request = isAuthRoute ? req.clone({ withCredentials: true }) : req;
const token = auth.getAccessToken();
if (token && !req.url.endsWith('/auth/login')) {
request = request.clone({ setHeaders: { Authorization: `Bearer ${token}` } });
}
return next(request).pipe(
catchError((error: unknown) => {
if (!(error instanceof HttpErrorResponse)) {
return throwError(() => error);
}
if (error.status === 403) {
const detail = (error.error as { detail?: string })?.detail;
if (detail === 'password_change_required') {
router.navigate(['/change-password']);
}
return throwError(() => error);
}
if (error.status !== 401 || req.url.endsWith('/auth/login')) {
return throwError(() => error);
}
if (req.url.endsWith('/auth/refresh')) {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => error);
}
const kind = parseAuthError(error);
if (kind === 'invalid_token') {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => error);
}
if (kind === 'expired' || kind === 'token_stale') {
return (auth.refreshShared() as Observable<TokenResponse>).pipe(
switchMap(() => {
const retried = request.clone({
setHeaders: { Authorization: `Bearer ${auth.getAccessToken()}` },
});
return next(retried);
}),
catchError((refreshError) => {
auth.clearSession();
router.navigate(['/login']);
return throwError(() => refreshError);
})
);
}
return throwError(() => error);
})
);
};
@@ -0,0 +1,86 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { AuthService } from './auth.service';
import { environment } from '../../../environments/environment';
describe('AuthService', () => {
let service: AuthService;
let httpMock: HttpTestingController;
const tokenResponse = {
access_token: 'abc123',
token_type: 'bearer',
expires_in: 900,
principal: {
id: '1',
email: 'a@a.com',
role: 'admin' as const,
kind: 'human' as const,
must_change_password: false,
},
};
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(AuthService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('stocke le token et le principal après un login réussi', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/auth/login`);
expect(req.request.withCredentials).toBe(true);
req.flush(tokenResponse);
expect(service.getAccessToken()).toBe('abc123');
expect(service.principal()?.email).toBe('a@a.com');
expect(service.isAuthenticated()).toBe(true);
});
it('efface la session au logout', () => {
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
service.logout().subscribe();
httpMock.expectOne(`${environment.apiUrl}/auth/logout`).flush(null);
expect(service.getAccessToken()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
});
it("ne déclenche qu'un seul appel réseau si refreshShared est appelé plusieurs fois avant la réponse", () => {
service.refreshShared().subscribe();
service.refreshShared().subscribe();
service.refreshShared().subscribe();
const requests = httpMock.match(`${environment.apiUrl}/auth/refresh`);
expect(requests.length).toBe(1);
requests[0].flush(tokenResponse);
});
it('met à jour la session après un changement de mot de passe réussi', () => {
service.changePassword({ current_password: 'old', new_password: 'new-password-1234' }).subscribe();
const req = httpMock.expectOne(`${environment.apiUrl}/auth/password`);
req.flush(tokenResponse);
expect(service.getAccessToken()).toBe('abc123');
});
it('récupère le principal courant via /auth/me', () => {
let result: unknown;
service.me().subscribe((r) => (result = r));
const req = httpMock.expectOne(`${environment.apiUrl}/auth/me`);
expect(req.request.method).toBe('GET');
req.flush(tokenResponse.principal);
expect(result).toEqual(tokenResponse.principal);
});
});
@@ -0,0 +1,69 @@
import { Service, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, tap, finalize, shareReplay } from 'rxjs';
import { LoginRequest, PasswordChangeRequest, Principal, TokenResponse } from '../../shared/models/auth.model';
import { environment } from '../../../environments/environment';
@Service()
export class AuthService {
private http = inject(HttpClient);
// Jamais de localStorage/sessionStorage/cookie côté JS : juste un signal en
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
private accessTokenSignal = signal<string | null>(null);
private principalSignal = signal<Principal | null>(null);
readonly principal = this.principalSignal.asReadonly();
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
private rotation$?: Observable<TokenResponse>;
getAccessToken(): string | null {
return this.accessTokenSignal();
}
private setSession(response: TokenResponse): void {
this.accessTokenSignal.set(response.access_token);
this.principalSignal.set(response.principal);
}
clearSession(): void {
this.accessTokenSignal.set(null);
this.principalSignal.set(null);
}
login(credentials: LoginRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
// appelants (sinon le serveur révoque toute la session sur des rotations concurrentes).
refreshShared(): Observable<TokenResponse> {
this.rotation$ ??= this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/refresh`, {}, { withCredentials: true })
.pipe(
tap((response) => this.setSession(response)),
finalize(() => (this.rotation$ = undefined)),
shareReplay(1)
);
return this.rotation$;
}
logout(): Observable<void> {
return this.http
.post<void>(`${environment.apiUrl}/auth/logout`, {}, { withCredentials: true })
.pipe(tap(() => this.clearSession()));
}
changePassword(payload: PasswordChangeRequest): Observable<TokenResponse> {
return this.http
.post<TokenResponse>(`${environment.apiUrl}/auth/password`, payload, { withCredentials: true })
.pipe(tap((response) => this.setSession(response)));
}
me(): Observable<Principal> {
return this.http.get<Principal>(`${environment.apiUrl}/auth/me`);
}
}
@@ -0,0 +1,31 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Nouveau mot de passe</h1>
<p class="auth-subtitle">Votre mot de passe est provisoire, vous devez le modifier avant de continuer</p>
<label for="current_password">Mot de passe actuel</label>
<input
id="current_password"
type="password"
formControlName="current_password"
autocomplete="current-password"
/>
<label for="new_password">Nouveau mot de passe</label>
<input
id="new_password"
type="password"
formControlName="new_password"
autocomplete="new-password"
/>
<span class="auth-hint">12 à 128 caractères</span>
@if (errorMessage()) {
<p class="auth-error">{{ errorMessage() }}</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Modification...' : 'Valider' }}
</button>
</form>
</div>
@@ -0,0 +1,88 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
line-height: 1.4;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-hint {
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.25rem;
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
@@ -0,0 +1,88 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { ChangePassword } from './change-password';
import { AuthService } from '../../../core/services/auth.service';
describe('ChangePassword', () => {
let authMock: { changePassword: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { changePassword: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [ChangePassword, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'old', new_password: 'trop-court' });
component.onSubmit();
expect(authMock.changePassword).not.toHaveBeenCalled();
});
it('redirige vers /dashboard après un changement réussi', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage())
expect(component.errorMessage()).toContain('incorrect');
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('incorrect');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(ChangePassword);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(ChangePassword);
const component = fixture.componentInstance;
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'un-nouveau-mot-de-passe-valide' });
fixture.detectChanges();
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.changePassword).toHaveBeenCalledWith({
current_password: 'ancien-mot-de-passe',
new_password: 'un-nouveau-mot-de-passe-valide',
});
});
});
@@ -0,0 +1,41 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-change-password',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './change-password.html',
styleUrl: './change-password.scss',
})
export class ChangePassword {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
errorMessage = signal<string | null>(null);
isLoading = signal(false);
form = this.fb.nonNullable.group({
current_password: ['', Validators.required],
new_password: ['', [Validators.required, Validators.minLength(12), Validators.maxLength(128)]],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.auth.changePassword(this.form.getRawValue()).subscribe({
next: (response) => {
this.router.navigate(['/dashboard']);
},
error: () => {
this.isLoading.set(false);
this.errorMessage.set('Mot de passe actuel incorrect, ou nouveau mot de passe invalide (12 à 128 caractères).');
},
});
}
}
@@ -0,0 +1,36 @@
<div class="auth-page">
<form class="auth-card" [formGroup]="form" (ngSubmit)="onSubmit()">
<h1>Connexion</h1>
<p class="auth-subtitle">Accédez à votre espace EnerVision</p>
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
autocomplete="username"
placeholder="vous@enervision.fr"
/>
<label for="password">Mot de passe</label>
<input
id="password"
type="password"
formControlName="password"
autocomplete="current-password"
/>
@if (errorMessage()) {
<p class="auth-error">
{{ errorMessage() }}
@if (retryAfterSeconds(); as seconds) {
(réessayez dans {{ seconds }}s)
}
</p>
}
<button type="submit" [disabled]="form.invalid || isLoading()">
{{ isLoading() ? 'Connexion...' : 'Se connecter' }}
</button>
</form>
</div>
@@ -0,0 +1,81 @@
:host {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f3f4f6;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.auth-card {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 12px;
padding: 2.5rem;
width: 100%;
max-width: 360px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.auth-subtitle {
margin: 0.25rem 0 1.5rem;
color: #6b7280;
font-size: 0.9rem;
}
label {
font-size: 0.85rem;
font-weight: 600;
color: #374151;
margin-bottom: 0.35rem;
margin-top: 1rem;
}
input {
padding: 0.6rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
}
button {
margin-top: 1.5rem;
padding: 0.7rem;
background: #3b82f6;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
&:disabled {
background: #9ca3af;
cursor: not-allowed;
}
&:not(:disabled):hover {
background: #2563eb;
}
}
}
.auth-error {
margin: 0.75rem 0 0;
color: #dc2626;
font-size: 0.85rem;
}
@@ -0,0 +1,110 @@
import { TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import { Login } from './login';
import { AuthService } from '../../../core/services/auth.service';
describe('Login', () => {
let authMock: { login: ReturnType<typeof vi.fn> };
let routerMock: { navigate: ReturnType<typeof vi.fn> };
beforeEach(async () => {
authMock = { login: vi.fn() };
routerMock = { navigate: vi.fn() };
await TestBed.configureTestingModule({
imports: [Login, ReactiveFormsModule],
providers: [
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
}).compileComponents();
});
it('ne soumet pas si le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login);
fixture.componentInstance.onSubmit();
expect(authMock.login).not.toHaveBeenCalled();
});
it('redirige vers /change-password si must_change_password est vrai', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
authMock.login.mockReturnValue(of({ principal: { role: 'admin', must_change_password: true } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/change-password']);
});
it('redirige vers /dashboard si le mot de passe est déjà à jour', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
component.onSubmit();
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
});
it('affiche un message générique sur un 401', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
authMock.login.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
component.onSubmit();
fixture.detectChanges(); // rend le bloc @if (errorMessage()) du template
expect(component.errorMessage()).toBe('Email ou mot de passe incorrect.');
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('Email ou mot de passe incorrect.');
});
it("affiche le délai d'attente sur un 429 avec Retry-After", () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'wrong' });
authMock.login.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 429, headers: new HttpHeaders({ 'Retry-After': '30' }) }))
);
component.onSubmit();
fixture.detectChanges(); // rend aussi le sous-bloc @if (retryAfterSeconds(); as seconds)
expect(component.retryAfterSeconds()).toBe(30);
const errorEl = fixture.nativeElement.querySelector('.auth-error');
expect(errorEl?.textContent).toContain('30s');
});
it('désactive le bouton tant que le formulaire est invalide', () => {
const fixture = TestBed.createComponent(Login);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('button[type="submit"]');
expect(button.disabled).toBe(true);
expect(fixture.nativeElement.querySelector('.auth-error')).toBeNull();
});
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
const fixture = TestBed.createComponent(Login);
const component = fixture.componentInstance;
component.form.setValue({ email: 'a@a.com', password: 'secret' });
fixture.detectChanges();
authMock.login.mockReturnValue(of({ principal: { role: 'lecteur', must_change_password: false } }));
const form = fixture.nativeElement.querySelector('form');
form.dispatchEvent(new Event('submit'));
fixture.detectChanges();
expect(authMock.login).toHaveBeenCalledWith({ email: 'a@a.com', password: 'secret' });
});
});
@@ -0,0 +1,55 @@
import { Component, inject, signal } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { HttpErrorResponse } from '@angular/common/http';
import { AuthService } from '../../../core/services/auth.service';
@Component({
selector: 'app-login',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './login.html',
styleUrl: './login.scss',
})
export class Login {
private fb = inject(FormBuilder);
private auth = inject(AuthService);
private router = inject(Router);
errorMessage = signal<string | null>(null);
retryAfterSeconds = signal<number | null>(null);
isLoading = signal(false);
form = this.fb.nonNullable.group({
email: ['', [Validators.required, Validators.email]],
password: ['', Validators.required],
});
onSubmit(): void {
if (this.form.invalid) return;
this.isLoading.set(true);
this.errorMessage.set(null);
this.retryAfterSeconds.set(null);
this.auth.login(this.form.getRawValue()).subscribe({
next: (response) => {
if (response.principal.must_change_password) {
this.router.navigate(['/change-password']);
return;
}
this.router.navigate(['/dashboard']);
},
error: (error: HttpErrorResponse) => {
this.isLoading.set(false);
if (error.status === 429) {
const retryAfter = error.headers.get('Retry-After');
this.retryAfterSeconds.set(retryAfter ? Number(retryAfter) : null);
this.errorMessage.set('Trop de tentatives, réessayez plus tard.');
return;
}
this.errorMessage.set('Email ou mot de passe incorrect.');
},
});
}
}
@@ -1,7 +1,10 @@
<div class="dashboard">
<header class="dashboard__header">
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
<div>
<h1>Vue d'ensemble</h1>
<p class="dashboard__subtitle">Consommation instantanée du parc</p>
</div>
<button type="button" class="logout-button" (click)="onLogout()">Déconnexion</button>
</header>
@if (error(); as message) {
@@ -144,3 +144,30 @@ h2 {
.alert-item__message {
font-size: 0.9rem;
}
.dashboard__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 2rem;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
}
.logout-button {
padding: 0.5rem 1rem;
background: #ffffff;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
color: #374151;
cursor: pointer;
&:hover {
background: #f3f4f6;
}
}
@@ -4,6 +4,8 @@ import { of, throwError } from 'rxjs';
import { Dashboard } from './dashboard';
import { StatsService } from '../../core/services/stats.service';
import { AlertsService } from '../../core/services/alerts.service';
import {AuthService} from '../../core/services/auth.service';
import {Router} from '@angular/router';
vi.mock('chart.js', () => {
class ChartMock {
@@ -92,4 +94,58 @@ describe('Dashboard', () => {
expect(fixture.componentInstance.alerts().length).toBe(0);
});
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.logout).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
const statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
const authMock = {
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
clearSession: vi.fn(),
};
const routerMock = { navigate: vi.fn() };
TestBed.configureTestingModule({
imports: [Dashboard],
providers: [
{ provide: StatsService, useValue: statsMock },
{ provide: AlertsService, useValue: alertsMock },
{ provide: AuthService, useValue: authMock },
{ provide: Router, useValue: routerMock },
],
});
const fixture = TestBed.createComponent(Dashboard);
fixture.detectChanges();
const button = fixture.nativeElement.querySelector('.logout-button');
button.click();
expect(authMock.clearSession).toHaveBeenCalled();
expect(routerMock.navigate).toHaveBeenCalledWith(['/login']);
});
});
@@ -2,10 +2,12 @@ import { Component, OnInit, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { timer, switchMap, catchError, EMPTY, Observable } from 'rxjs';
import { DecimalPipe } from '@angular/common';
import { Router } from '@angular/router';
import { StatsService } from '../../core/services/stats.service';
import { ConsumptionGauge } from '../../shared/components/consumption-gauge/consumption-gauge';
import { SiteLoadChart } from '../../shared/components/site-load-chart/site-load-chart';
import { AlertsService } from '../../core/services/alerts.service';
import { AuthService } from '../../core/services/auth.service';
import { StatsSummary } from '../../shared/models/stats.model';
import { Alert } from '../../shared/models/alert.model';
@@ -23,6 +25,8 @@ const UNAVAILABLE_MESSAGE =
export class Dashboard implements OnInit {
private statsService = inject(StatsService);
private alertsService = inject(AlertsService);
private auth = inject(AuthService);
private router = inject(Router);
private destroyRef = inject(DestroyRef);
stats = signal<StatsSummary | null>(null);
@@ -50,6 +54,17 @@ export class Dashboard implements OnInit {
});
}
onLogout(): void {
this.auth.logout().subscribe({
next: () => this.router.navigate(['/login']),
error: () => {
// Même si l'appel réseau échoue, on considère l'utilisateur déconnecté localement.
this.auth.clearSession();
this.router.navigate(['/login']);
},
});
}
private reportUnavailable(): Observable<never> {
this.error.set(UNAVAILABLE_MESSAGE);
return EMPTY;
@@ -0,0 +1,26 @@
export type Role = 'lecteur' | 'operateur' | 'admin';
export interface LoginRequest {
email: string;
password: string;
}
export interface PasswordChangeRequest {
current_password: string;
new_password: string;
}
export interface Principal {
id: string;
email: string;
role: Role;
kind: 'human';
must_change_password: boolean;
}
export interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
principal: Principal;
}
@@ -1,5 +1,5 @@
export const environment = {
production: true,
apiUrl: 'http://localhost:8000/api/v1',
apiUrl: '/api/v1',
useMockFixtures: false,
};