Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3550dde2 | ||
|
|
7f710c9084 |
@@ -57,66 +57,6 @@ jobs:
|
|||||||
- name: Tests et couverture
|
- name: Tests et couverture
|
||||||
run: uv run pytest --cov-fail-under=85
|
run: uv run pytest --cov-fail-under=85
|
||||||
|
|
||||||
# Piège : l'image est celle de docker-compose.yml, pas une image `postgres` nue. La première
|
|
||||||
# migration (`5353c0e4f094`) échoue volontairement si l'extension TimescaleDB manque, et un
|
|
||||||
# écart d'image entre la CI et le poste rendrait ce job vert sur une base qui n'est pas la nôtre.
|
|
||||||
integration:
|
|
||||||
name: Tests exigeant une base
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: apps/backend
|
|
||||||
|
|
||||||
services:
|
|
||||||
db:
|
|
||||||
image: timescale/timescaledb-ha:pg17
|
|
||||||
env:
|
|
||||||
POSTGRES_USER: enervision
|
|
||||||
POSTGRES_PASSWORD: change_me
|
|
||||||
POSTGRES_DB: enervision_test
|
|
||||||
ports:
|
|
||||||
- "5433:5432"
|
|
||||||
options: >-
|
|
||||||
--health-cmd "pg_isready -U enervision -d enervision_test"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 12
|
|
||||||
--health-start-period 40s
|
|
||||||
|
|
||||||
env:
|
|
||||||
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test
|
|
||||||
APP_SECRET_KEY: secret-de-test-assez-long-pour-le-validateur
|
|
||||||
PGPASSWORD: change_me
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Récupère le dépôt
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Installe uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
|
||||||
|
|
||||||
- name: Installe l'interpréteur déclaré par .python-version
|
|
||||||
run: uv python install
|
|
||||||
|
|
||||||
- name: Synchronise les dépendances sans dévier du verrou
|
|
||||||
run: uv sync --all-groups --frozen
|
|
||||||
|
|
||||||
# Sur le poste, c'est db/init/110-test-database.sql qui pose l'extension. Ce fichier n'est
|
|
||||||
# pas monté ici, et sans lui `alembic upgrade head` s'arrête sur la garde de la révision 1.
|
|
||||||
- name: Active TimescaleDB sur la base de test
|
|
||||||
run: psql -h localhost -p 5433 -U enervision -d enervision_test -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
|
|
||||||
|
|
||||||
- name: Applique les migrations
|
|
||||||
run: uv run alembic upgrade head
|
|
||||||
|
|
||||||
# `-m` en ligne de commande écrase celui d'`addopts`. La couverture est désactivée : ce job
|
|
||||||
# ne joue qu'une partie de la suite, son taux n'aurait aucun sens face au seuil de 85 %.
|
|
||||||
- name: Tests d'intégration
|
|
||||||
run: uv run pytest -m integration --no-cov
|
|
||||||
|
|
||||||
security-audit:
|
security-audit:
|
||||||
name: Audit des dépendances
|
name: Audit des dépendances
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -51,14 +51,30 @@ jobs:
|
|||||||
node-version: 24
|
node-version: 24
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
cache-dependency-path: apps/frontend/package-lock.json
|
||||||
- name : Installation des dépendances (Front)
|
- run: npm ci
|
||||||
run: npm ci
|
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
- name : Lancement des tests et génénration du rapport de couverture (Front)
|
- run: npm test -- --watch=false --coverage
|
||||||
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
|
||||||
working-directory: apps/frontend
|
working-directory: apps/frontend
|
||||||
- name: Upload coverage
|
- name: Upload coverage
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: frontend-coverage
|
name: frontend-coverage
|
||||||
path: apps/frontend/coverage/frontend/lcov.info
|
path: apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|
||||||
|
sonarqube:
|
||||||
|
needs: [build, test]
|
||||||
|
name: SonarQube
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Download coverage
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: frontend-coverage
|
||||||
|
path: apps/frontend/coverage/frontend
|
||||||
|
- name: SonarQube Scan
|
||||||
|
uses: SonarSource/sonarqube-scan-action@v8
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
name: SonarQube
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "apps/frontend/**"
|
|
||||||
- "apps/backend/**"
|
|
||||||
- ".github/workflows/sonarqube.yml"
|
|
||||||
pull_request:
|
|
||||||
paths:
|
|
||||||
- "apps/frontend/**"
|
|
||||||
- "apps/backend/**"
|
|
||||||
- ".github/workflows/sonarqube.yml"
|
|
||||||
|
|
||||||
|
|
||||||
# Build l'ensemble du projet, puis lance les tests
|
|
||||||
# Génère les rapports de couverture, puis lance l'analyse SonarQube
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-front:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
|
||||||
|
|
||||||
- run: npm ci
|
|
||||||
working-directory: apps/frontend
|
|
||||||
- run: npm run build
|
|
||||||
working-directory: apps/frontend
|
|
||||||
|
|
||||||
test-front:
|
|
||||||
needs: build-front
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 24
|
|
||||||
cache: npm
|
|
||||||
cache-dependency-path: apps/frontend/package-lock.json
|
|
||||||
|
|
||||||
- name : Installation des dépendances (Front)
|
|
||||||
run: npm ci
|
|
||||||
working-directory: apps/frontend
|
|
||||||
|
|
||||||
- name : Lancement des tests et génénration du rapport de couverture (Front)
|
|
||||||
run: npm test --watch=false --code-coverage --coverageReporters=lcov
|
|
||||||
working-directory: apps/frontend
|
|
||||||
|
|
||||||
- name: Upload coverage
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-coverage
|
|
||||||
path: apps/frontend/coverage/frontend/lcov.info
|
|
||||||
|
|
||||||
build-back:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- name: Installe uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
|
||||||
- name: Installe l'interpréteur déclaré par .python-version
|
|
||||||
run: uv python install
|
|
||||||
working-directory: apps/backend
|
|
||||||
|
|
||||||
- name: Synchronise les dépendances sans dévier du verrou
|
|
||||||
run: uv sync --all-groups --frozen
|
|
||||||
working-directory: apps/backend
|
|
||||||
|
|
||||||
- name: Vérifie le formatage
|
|
||||||
run: uv run ruff format --check .
|
|
||||||
working-directory: apps/backend
|
|
||||||
|
|
||||||
- name: Analyse statique
|
|
||||||
run: uv run ruff check --output-format=github .
|
|
||||||
working-directory: apps/backend
|
|
||||||
|
|
||||||
- name: Typage
|
|
||||||
run: uv run mypy app
|
|
||||||
working-directory: apps/backend
|
|
||||||
|
|
||||||
|
|
||||||
test-back:
|
|
||||||
needs: build-back
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
- name: Installe uv
|
|
||||||
uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
enable-cache: true
|
|
||||||
cache-dependency-glob: apps/backend/uv.lock
|
|
||||||
|
|
||||||
- name : Lancement des tests et génénration du rapport de couverture (Back)
|
|
||||||
run: uv run pytest --cov-fail-under=85 --cov-report=xml
|
|
||||||
working-directory: apps/backend
|
|
||||||
|
|
||||||
- name: Upload coverage
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: backend-coverage
|
|
||||||
path: apps/backend/coverage.xml
|
|
||||||
|
|
||||||
sonarqube:
|
|
||||||
needs: [build-front, build-back, test-front, test-back]
|
|
||||||
name: SonarQube
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
- name: Téléchargement du rapport de couverture (Front)
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: frontend-coverage
|
|
||||||
path: apps/frontend/coverage/frontend
|
|
||||||
- name: Téléchargement du rapport de couverture (Back)
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: backend-coverage
|
|
||||||
path: apps/backend
|
|
||||||
- name: SonarQube Scan
|
|
||||||
uses: SonarSource/sonarqube-scan-action@v8
|
|
||||||
env:
|
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
|
||||||
+3
-21
@@ -123,11 +123,6 @@ async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> Non
|
|||||||
defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre
|
defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre
|
||||||
declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus.
|
declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus.
|
||||||
|
|
||||||
Ces tests ne sont pas pour autant facultatifs : le job `integration` de
|
|
||||||
`.github/workflows/backend.yml` monte un service TimescaleDB, applique les migrations et
|
|
||||||
les joue a chaque poussee. Un test `integration` casse donc la CI comme un autre. En local,
|
|
||||||
`make db-up` puis `make test-integration`.
|
|
||||||
|
|
||||||
## Couverture
|
## Couverture
|
||||||
|
|
||||||
Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique
|
Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique
|
||||||
@@ -147,27 +142,14 @@ uv run pytest tests/api/test_health.py # un seul fichier
|
|||||||
uv run pytest -k readiness # par motif de nom
|
uv run pytest -k readiness # par motif de nom
|
||||||
```
|
```
|
||||||
|
|
||||||
## Quatre fichiers à connaître avant de toucher à l'authentification
|
## Trois fichiers à connaître avant de toucher à l'authentification
|
||||||
|
|
||||||
`tests/api/acces.py` porte la classification des routes du contrat, en quatre ensembles :
|
|
||||||
`ROUTES_PUBLIQUES`, `ROUTE_COOKIE`, `ROUTES_SANS_ROLE` et la table `ROLE_MINIMUM`. Ce n'est pas
|
|
||||||
un fichier de test, c'est la référence que les trois autres confrontent au comportement observé.
|
|
||||||
**Toute route ajoutée doit y être classée** : `test_every_declared_route_is_classified` échoue
|
|
||||||
sinon, et échoue aussi sur une entrée qui ne correspond plus à aucune route.
|
|
||||||
|
|
||||||
`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et
|
`tests/api/test_route_protection.py` interroge réellement chaque route sans identifiant et
|
||||||
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de
|
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Il n'inspecte pas l'arbre de
|
||||||
dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route
|
dépendances : celui-ci n'est accessible que par l'API privée de FastAPI, et surtout une route
|
||||||
peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose
|
peut porter la bonne dépendance tout en répondant quand même. **Rendre une route publique impose
|
||||||
donc de modifier `ROUTES_PUBLIQUES` dans `acces.py`**, ce qui apparaît en clair dans la diff
|
donc de modifier la liste `ROUTES_PUBLIQUES` de ce fichier**, ce qui apparaît en clair dans la
|
||||||
d'une pull request.
|
diff d'une pull request.
|
||||||
|
|
||||||
`tests/api/test_matrice_acces.py` croise chaque route gardée avec chacun des trois rôles, dans
|
|
||||||
les deux sens : un rôle insuffisant reçoit un 403 `Droits insuffisants`, un rôle suffisant ne le
|
|
||||||
reçoit jamais. Le second sens est ce qui rend visible une garde posée trop haut, par exemple
|
|
||||||
`AdminDep` sur une route de lecture. La même matrice est rejouée sous `integration` avec de vrais
|
|
||||||
jetons, donc en traversant le décodage du JWT et la relecture du compte en base, que
|
|
||||||
`dependency_overrides` court-circuite.
|
|
||||||
|
|
||||||
`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend
|
`tests/services/test_auth.py` donne au faux hacheur un **compteur d'appels**. C'est ce qui rend
|
||||||
possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test
|
possibles les deux assertions qui prouvent la conception, et qu'aucune autre forme de test
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,86 +0,0 @@
|
|||||||
# Pourquoi : classification unique des routes du contrat, lue par test_route_protection.py,
|
|
||||||
# test_openapi.py et test_matrice_acces.py. Trois listes séparées dérivaient auparavant chacune
|
|
||||||
# de leur côté, et deux entrées de ROUTES_A_ROLE ne correspondaient plus à aucune route sans que
|
|
||||||
# rien ne le signale.
|
|
||||||
# Piège : les trois ensembles doivent rester disjoints et couvrir tout le schéma. C'est
|
|
||||||
# `test_every_declared_route_is_classified` qui le vérifie, pas la relecture.
|
|
||||||
|
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from app.core.roles import Role
|
|
||||||
|
|
||||||
Route = tuple[str, str]
|
|
||||||
|
|
||||||
ROUTES_PUBLIQUES: Final[frozenset[Route]] = frozenset(
|
|
||||||
{
|
|
||||||
("GET", "/api/v1/health/live"),
|
|
||||||
("GET", "/api/v1/health/ready"),
|
|
||||||
("POST", "/api/v1/auth/login"),
|
|
||||||
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
|
|
||||||
("POST", "/api/v1/auth/logout"),
|
|
||||||
("POST", "/api/v1/auth/forgot-password"),
|
|
||||||
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
|
|
||||||
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
|
|
||||||
("POST", "/api/v1/auth/reset-password"),
|
|
||||||
# Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un
|
|
||||||
# `Principal`. Le jeton est un secret de 256 bits, non brute-forçable.
|
|
||||||
("GET", "/api/v1/auth/reset-password/validate"),
|
|
||||||
("GET", "/metrics"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Le cookie opaque porte seul l'autorisation : sans lui la route rend 401, mais aucun `Principal`
|
|
||||||
# n'est construit et `require_role` n'entre jamais en jeu.
|
|
||||||
ROUTE_COOKIE: Final[frozenset[Route]] = frozenset({("POST", "/api/v1/auth/refresh")})
|
|
||||||
|
|
||||||
# Authentifiées par `CurrentPrincipalDep` nu, donc hors de `require_role` et, avec lui, hors du
|
|
||||||
# refus `password_change_required`. Volontaire pour `/auth/password`, qui est la sortie de l'état
|
|
||||||
# provisoire ; subi pour `/auth/logout-all`, cf. test_matrice_acces.py.
|
|
||||||
ROUTES_SANS_ROLE: Final[frozenset[Route]] = frozenset(
|
|
||||||
{
|
|
||||||
("GET", "/api/v1/auth/me"),
|
|
||||||
("POST", "/api/v1/auth/password"),
|
|
||||||
("POST", "/api/v1/auth/logout-all"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
ROLE_MINIMUM: Final[dict[Route, Role]] = {
|
|
||||||
("GET", "/api/v1/sites"): Role.LECTEUR,
|
|
||||||
("GET", "/api/v1/sites/{site_id}"): Role.LECTEUR,
|
|
||||||
("GET", "/api/v1/sites/{site_id}/current"): Role.LECTEUR,
|
|
||||||
("GET", "/api/v1/alerts"): Role.LECTEUR,
|
|
||||||
("GET", "/api/v1/recommendations"): Role.LECTEUR,
|
|
||||||
("GET", "/api/v1/recommendations/{recommendation_id}"): Role.LECTEUR,
|
|
||||||
("GET", "/api/v1/stats/summary"): Role.LECTEUR,
|
|
||||||
("GET", "/api/v1/readings"): Role.LECTEUR,
|
|
||||||
("GET", "/api/v1/sensors/status"): Role.ADMIN,
|
|
||||||
("GET", "/api/v1/users"): Role.ADMIN,
|
|
||||||
("POST", "/api/v1/users"): Role.ADMIN,
|
|
||||||
("PATCH", "/api/v1/users/{user_id}"): Role.ADMIN,
|
|
||||||
("POST", "/api/v1/users/{user_id}/password-reset"): Role.ADMIN,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Piège : `{recommendation_id}` est typé `int` et `{user_id}` est un UUID. Une substitution
|
|
||||||
# uniforme par une chaîne quelconque rendrait 422 avant d'atteindre la garde de rôle, et le test
|
|
||||||
# passerait en prouvant autre chose que ce qu'il annonce.
|
|
||||||
SUBSTITUTIONS: Final[dict[str, str]] = {
|
|
||||||
"{user_id}": "00000000-0000-0000-0000-000000000000",
|
|
||||||
"{site_id}": "site-absent-du-jeu-de-donnees",
|
|
||||||
"{recommendation_id}": "999999999",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def chemin_concret(chemin: str) -> str:
|
|
||||||
for gabarit, valeur in SUBSTITUTIONS.items():
|
|
||||||
chemin = chemin.replace(gabarit, valeur)
|
|
||||||
return chemin
|
|
||||||
|
|
||||||
|
|
||||||
def routes_du_schema(schema: dict[str, object]) -> list[Route]:
|
|
||||||
chemins: dict[str, dict[str, object]] = schema["paths"] # type: ignore[assignment]
|
|
||||||
return [
|
|
||||||
(methode.upper(), chemin)
|
|
||||||
for chemin, operations in chemins.items()
|
|
||||||
for methode in operations
|
|
||||||
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
|
|
||||||
]
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
# Pourquoi : la matrice rôle x route sur les routes réelles. `test_authorization.py` la joue déjà,
|
|
||||||
# mais contre une route jetable montée par une fixture, ce qui ne dit rien du niveau effectivement
|
|
||||||
# posé sur `/sites` ou `/users`. `ROLE_MINIMUM` (tests/api/acces.py) est la référence, et ce
|
|
||||||
# fichier est ce qui la confronte au comportement observé.
|
|
||||||
# Piège : l'assertion porte sur le refus de la garde, pas sur un 200. Un rôle suffisant peut
|
|
||||||
# légitimement recevoir 404 ou 422 selon les données ; ce qui compte est qu'il ne reçoive pas le
|
|
||||||
# 403 `Droits insuffisants`. Sans cette nuance, le test dépendrait du contenu de la base.
|
|
||||||
# Les tests `integration` en fin de fichier rejouent la même matrice avec de vrais jetons, donc en
|
|
||||||
# traversant le décodage du JWT et la relecture du compte, ce que l'override court-circuite.
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from collections.abc import AsyncIterator, Callable, Iterator
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from httpx import AsyncClient, Response
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from app.api.deps import get_current_principal
|
|
||||||
from app.core.hashing import build_hasher
|
|
||||||
from app.core.principal import Principal
|
|
||||||
from app.core.roles import AccountKind, Role, has_at_least
|
|
||||||
from app.db.session import get_session, get_session_factory
|
|
||||||
from app.repositories.user import UserRepository
|
|
||||||
from tests.api.acces import ROLE_MINIMUM, chemin_concret
|
|
||||||
|
|
||||||
ROLES = [Role.LECTEUR, Role.OPERATEUR, Role.ADMIN]
|
|
||||||
IDS_DE_ROLE = ["lecteur", "operateur", "admin"]
|
|
||||||
REFUS_DE_DROITS = "Droits insuffisants"
|
|
||||||
REFUS_DE_MOT_DE_PASSE = "password_change_required"
|
|
||||||
MOT_DE_PASSE = "un-mot-de-passe-de-recette"
|
|
||||||
|
|
||||||
|
|
||||||
# `FakeSession` de tests/factories.py rend un unique objet pour les trois formes d'appel, ce qui
|
|
||||||
# suffit à un test d'endpoint ciblé mais pas à balayer 13 routes qui interrogent chacune la base
|
|
||||||
# à sa façon. Ce double rend un résultat vide quelle que soit la forme demandée, pour que la
|
|
||||||
# réponse observée vienne de la garde de rôle et jamais d'un double mal ajusté.
|
|
||||||
class ResultatVide:
|
|
||||||
def scalars(self) -> ResultatVide:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def all(self) -> list[object]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
def first(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def one_or_none(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def scalar_one_or_none(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def mappings(self) -> ResultatVide:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator[object]:
|
|
||||||
return iter(())
|
|
||||||
|
|
||||||
|
|
||||||
class SessionMuette:
|
|
||||||
async def scalar(self, *_: object, **__: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def execute(self, *_: object, **__: object) -> ResultatVide:
|
|
||||||
return ResultatVide()
|
|
||||||
|
|
||||||
async def scalars(self, *_: object, **__: object) -> ResultatVide:
|
|
||||||
return ResultatVide()
|
|
||||||
|
|
||||||
async def get(self, *_: object, **__: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def flush(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def commit(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def rollback(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def add(self, *_: object, **__: object) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def base_muette(app: FastAPI) -> None:
|
|
||||||
async def override() -> AsyncIterator[SessionMuette]:
|
|
||||||
yield SessionMuette()
|
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = override
|
|
||||||
|
|
||||||
|
|
||||||
def principal(role: Role, *, must_change_password: bool = False) -> Principal:
|
|
||||||
return Principal(
|
|
||||||
id=uuid.uuid4(),
|
|
||||||
email=f"matrice-{role.value}@enervision.fr",
|
|
||||||
role=role,
|
|
||||||
kind=AccountKind.HUMAIN,
|
|
||||||
must_change_password=must_change_password,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def connecte(app: FastAPI) -> Iterator[Callable[[Principal], None]]:
|
|
||||||
def installe(acteur: Principal) -> None:
|
|
||||||
app.dependency_overrides[get_current_principal] = lambda: acteur
|
|
||||||
|
|
||||||
yield installe
|
|
||||||
app.dependency_overrides.pop(get_current_principal, None)
|
|
||||||
|
|
||||||
|
|
||||||
async def appelle(client: AsyncClient, methode: str, chemin: str, **kwargs: object) -> Response:
|
|
||||||
return await client.request(methode, chemin_concret(chemin), json={}, **kwargs) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
def motif_du_refus(response: Response) -> str | None:
|
|
||||||
if response.status_code != 403:
|
|
||||||
return None
|
|
||||||
detail = response.json().get("detail")
|
|
||||||
return detail if isinstance(detail, str) else None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
|
|
||||||
async def test_a_role_below_the_minimum_is_refused_on_every_guarded_route(
|
|
||||||
connecte: Callable[[Principal], None],
|
|
||||||
client: AsyncClient,
|
|
||||||
base_muette: None,
|
|
||||||
role: Role,
|
|
||||||
) -> None:
|
|
||||||
connecte(principal(role))
|
|
||||||
laissees_passer: list[tuple[str, str, int]] = []
|
|
||||||
|
|
||||||
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
|
||||||
if has_at_least(role, minimum):
|
|
||||||
continue
|
|
||||||
response = await appelle(client, methode, chemin)
|
|
||||||
if motif_du_refus(response) != REFUS_DE_DROITS:
|
|
||||||
laissees_passer.append((methode, chemin, response.status_code))
|
|
||||||
|
|
||||||
assert laissees_passer == []
|
|
||||||
|
|
||||||
|
|
||||||
# Le pendant du test précédent : sans lui, une garde posée trop haut, par exemple `AdminDep` sur
|
|
||||||
# `/sites`, ne ferait échouer aucun test du dépôt.
|
|
||||||
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
|
|
||||||
async def test_a_role_at_or_above_the_minimum_is_never_refused_by_the_guard(
|
|
||||||
connecte: Callable[[Principal], None],
|
|
||||||
client: AsyncClient,
|
|
||||||
base_muette: None,
|
|
||||||
role: Role,
|
|
||||||
) -> None:
|
|
||||||
connecte(principal(role))
|
|
||||||
refusees: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
|
||||||
if not has_at_least(role, minimum):
|
|
||||||
continue
|
|
||||||
response = await appelle(client, methode, chemin)
|
|
||||||
if motif_du_refus(response) == REFUS_DE_DROITS:
|
|
||||||
refusees.append((methode, chemin))
|
|
||||||
|
|
||||||
assert refusees == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_a_pending_password_change_is_refused_on_every_guarded_route(
|
|
||||||
connecte: Callable[[Principal], None],
|
|
||||||
client: AsyncClient,
|
|
||||||
base_muette: None,
|
|
||||||
) -> None:
|
|
||||||
connecte(principal(Role.ADMIN, must_change_password=True))
|
|
||||||
laissees_passer: list[tuple[str, str, int]] = []
|
|
||||||
|
|
||||||
for methode, chemin in ROLE_MINIMUM:
|
|
||||||
response = await appelle(client, methode, chemin)
|
|
||||||
if motif_du_refus(response) != REFUS_DE_MOT_DE_PASSE:
|
|
||||||
laissees_passer.append((methode, chemin, response.status_code))
|
|
||||||
|
|
||||||
assert laissees_passer == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def comptes_par_role() -> AsyncIterator[dict[Role, str]]:
|
|
||||||
marque = uuid.uuid4().hex[:12]
|
|
||||||
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
|
|
||||||
empreinte = await hacheur.hash(MOT_DE_PASSE)
|
|
||||||
adresses = {role: f"matrice-{marque}-{role.value}@enervision.fr" for role in ROLES}
|
|
||||||
|
|
||||||
async with get_session_factory()() as session:
|
|
||||||
depot = UserRepository(session)
|
|
||||||
for role, email in adresses.items():
|
|
||||||
await depot.create(email=email, password_hash=empreinte, role=role)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
yield adresses
|
|
||||||
|
|
||||||
async with get_session_factory()() as session:
|
|
||||||
await session.execute(
|
|
||||||
text("delete from app_user where email like :motif"), {"motif": f"matrice-{marque}-%"}
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def authentifie(client: AsyncClient, email: str) -> dict[str, str]:
|
|
||||||
reponse = await client.post(
|
|
||||||
"/api/v1/auth/login", json={"email": email, "password": MOT_DE_PASSE}
|
|
||||||
)
|
|
||||||
assert reponse.status_code == 200, reponse.text
|
|
||||||
return {"Authorization": f"Bearer {reponse.json()['access_token']}"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
@pytest.mark.parametrize("role", ROLES, ids=IDS_DE_ROLE)
|
|
||||||
async def test_a_real_token_reaches_exactly_the_routes_of_its_rank(
|
|
||||||
comptes_par_role: dict[Role, str], client: AsyncClient, role: Role
|
|
||||||
) -> None:
|
|
||||||
entetes = await authentifie(client, comptes_par_role[role])
|
|
||||||
ecarts: list[tuple[str, str, int, str]] = []
|
|
||||||
|
|
||||||
for (methode, chemin), minimum in ROLE_MINIMUM.items():
|
|
||||||
response = await appelle(client, methode, chemin, headers=entetes)
|
|
||||||
refuse = motif_du_refus(response) == REFUS_DE_DROITS
|
|
||||||
if refuse is has_at_least(role, minimum):
|
|
||||||
ecarts.append((methode, chemin, response.status_code, response.text[:120]))
|
|
||||||
|
|
||||||
assert ecarts == []
|
|
||||||
|
|
||||||
|
|
||||||
# Contrainte : `operateur` n'ouvre aujourd'hui aucune route de plus que `lecteur`, faute d'écriture
|
|
||||||
# métier dans l'API. Figer l'égalité rend la régression visible le jour où une route d'opérateur
|
|
||||||
# arrive sans que `ROLE_MINIMUM` soit mis à jour.
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_the_operator_rank_opens_nothing_more_than_the_reader_rank(
|
|
||||||
comptes_par_role: dict[Role, str], client: AsyncClient
|
|
||||||
) -> None:
|
|
||||||
lecteur = await authentifie(client, comptes_par_role[Role.LECTEUR])
|
|
||||||
operateur = await authentifie(client, comptes_par_role[Role.OPERATEUR])
|
|
||||||
divergences: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
for methode, chemin in ROLE_MINIMUM:
|
|
||||||
cote_lecteur = await appelle(client, methode, chemin, headers=lecteur)
|
|
||||||
cote_operateur = await appelle(client, methode, chemin, headers=operateur)
|
|
||||||
if cote_lecteur.status_code != cote_operateur.status_code:
|
|
||||||
divergences.append((methode, chemin))
|
|
||||||
|
|
||||||
assert divergences == []
|
|
||||||
|
|
||||||
|
|
||||||
# Piège : `/auth/logout-all` prend un `CurrentPrincipalDep` nu, donc elle échappe au gate
|
|
||||||
# `must_change_password` que seul `require_role` applique. Comportement figé ici, pas corrigé.
|
|
||||||
@pytest.mark.integration
|
|
||||||
async def test_a_temporary_password_blocks_the_business_routes_but_not_logout_all(
|
|
||||||
client: AsyncClient,
|
|
||||||
) -> None:
|
|
||||||
marque = uuid.uuid4().hex[:12]
|
|
||||||
email = f"matrice-{marque}-provisoire@enervision.fr"
|
|
||||||
hacheur = build_hasher(time_cost=1, memory_cost_kib=8192, parallelism=1, max_concurrency=2)
|
|
||||||
empreinte = await hacheur.hash(MOT_DE_PASSE)
|
|
||||||
|
|
||||||
async with get_session_factory()() as session:
|
|
||||||
await UserRepository(session).create(
|
|
||||||
email=email, password_hash=empreinte, role=Role.ADMIN, must_change_password=True
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
try:
|
|
||||||
entetes = await authentifie(client, email)
|
|
||||||
sites = await client.get("/api/v1/sites", headers=entetes)
|
|
||||||
identite = await client.get("/api/v1/auth/me", headers=entetes)
|
|
||||||
fermeture = await client.post("/api/v1/auth/logout-all", headers=entetes)
|
|
||||||
|
|
||||||
assert motif_du_refus(sites) == REFUS_DE_MOT_DE_PASSE
|
|
||||||
assert identite.status_code == 200
|
|
||||||
assert fermeture.status_code == 204
|
|
||||||
finally:
|
|
||||||
async with get_session_factory()() as session:
|
|
||||||
await session.execute(text("delete from app_user where email = :e"), {"e": email})
|
|
||||||
await session.commit()
|
|
||||||
@@ -8,7 +8,6 @@ from typing import Any
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app import cli
|
from app import cli
|
||||||
from tests.api.acces import ROLE_MINIMUM
|
|
||||||
|
|
||||||
METHODES = {"get", "post", "patch", "put", "delete"}
|
METHODES = {"get", "post", "patch", "put", "delete"}
|
||||||
|
|
||||||
@@ -25,11 +24,21 @@ ORIGINE_VERIFIEE = {
|
|||||||
|
|
||||||
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
|
# Toute route derrière `require_role` (LecteurDep, OperateurDep, AdminDep) peut rendre 403 pour
|
||||||
# `password_change_required`, pas seulement les routes `admin`.
|
# `password_change_required`, pas seulement les routes `admin`.
|
||||||
# Piège : cette liste était recopiée ici, et deux de ses entrées portaient `{id}` là où le contrat
|
ROUTES_A_ROLE = {
|
||||||
# expose `{user_id}`. Elles ne correspondaient donc à aucune opération, et le test ci-dessous
|
("GET", "/api/v1/users"),
|
||||||
# passait au vert sans rien vérifier sur ces deux routes. Elle est maintenant dérivée, et
|
("POST", "/api/v1/users"),
|
||||||
# `test_every_declared_route_is_classified` interdit l'entrée morte.
|
("PATCH", "/api/v1/users/{id}"),
|
||||||
ROUTES_A_ROLE = frozenset(ROLE_MINIMUM)
|
("POST", "/api/v1/users/{id}/password-reset"),
|
||||||
|
("GET", "/api/v1/sites"),
|
||||||
|
("GET", "/api/v1/sites/{site_id}"),
|
||||||
|
("GET", "/api/v1/sites/{site_id}/current"),
|
||||||
|
("GET", "/api/v1/alerts"),
|
||||||
|
("GET", "/api/v1/recommendations"),
|
||||||
|
("GET", "/api/v1/recommendations/{recommendation_id}"),
|
||||||
|
("GET", "/api/v1/stats/summary"),
|
||||||
|
("GET", "/api/v1/readings"),
|
||||||
|
("GET", "/api/v1/sensors/status"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier
|
# Ce test est le garde-fou de l'autorisation : rendre une route publique oblige à modifier
|
||||||
# `ROUTES_PUBLIQUES` dans `tests/api/acces.py`, ce qui apparaît en clair dans la diff d'une pull
|
# `ROUTES_PUBLIQUES` ci-dessous, ce qui apparaît en clair dans la diff d'une pull request et
|
||||||
# request et demande une justification au relecteur.
|
# demande une justification au relecteur.
|
||||||
# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de
|
# Pourquoi : il interroge réellement chaque route sans jeton au lieu d'inspecter l'arbre de
|
||||||
# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route
|
# dépendances. L'arbre n'est accessible que par l'API privée de FastAPI, et surtout une route
|
||||||
# peut porter la bonne dépendance tout en répondant quand même.
|
# peut porter la bonne dépendance tout en répondant quand même.
|
||||||
@@ -11,64 +11,58 @@ import pytest
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.api.acces import (
|
ROUTES_PUBLIQUES = frozenset(
|
||||||
ROLE_MINIMUM,
|
{
|
||||||
ROUTE_COOKIE,
|
("GET", "/api/v1/health/live"),
|
||||||
ROUTES_PUBLIQUES,
|
("GET", "/api/v1/health/ready"),
|
||||||
ROUTES_SANS_ROLE,
|
("POST", "/api/v1/auth/login"),
|
||||||
Route,
|
# Sans cookie, la déconnexion ne fait rien et répond 204 : elle est idempotente.
|
||||||
chemin_concret,
|
("POST", "/api/v1/auth/logout"),
|
||||||
routes_du_schema,
|
("POST", "/api/v1/auth/forgot-password"),
|
||||||
|
# Protégée par le jeton dans le corps de la requête, pas par un `Principal` : aucune
|
||||||
|
# authentification préalable ne s'applique, c'est la validité du jeton qui tranche.
|
||||||
|
("POST", "/api/v1/auth/reset-password"),
|
||||||
|
# Même raison : lecture seule, protégée par le jeton passé en paramètre, pas par un
|
||||||
|
# `Principal`. Le jeton est un secret de 256 bits, non brute-forçable.
|
||||||
|
("GET", "/api/v1/auth/reset-password/validate"),
|
||||||
|
("GET", "/metrics"),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
VALEURS_DE_SUBSTITUTION = "00000000-0000-0000-0000-000000000000"
|
||||||
STATUTS_DE_REFUS = {401, 403}
|
STATUTS_DE_REFUS = {401, 403}
|
||||||
HORS_SCHEMA = {("GET", "/metrics")}
|
|
||||||
|
|
||||||
|
|
||||||
def routes_declarees(app: FastAPI) -> list[Route]:
|
def routes_declarees(app: FastAPI) -> list[tuple[str, str]]:
|
||||||
schema: dict[str, Any] = app.openapi()
|
schema: dict[str, Any] = app.openapi()
|
||||||
return routes_du_schema(schema)
|
return [
|
||||||
|
(methode.upper(), chemin)
|
||||||
|
for chemin, operations in schema["paths"].items()
|
||||||
|
for methode in operations
|
||||||
|
if methode.upper() in {"GET", "POST", "PATCH", "PUT", "DELETE"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def routes_protegees(app: FastAPI) -> list[Route]:
|
def routes_protegees(app: FastAPI) -> list[tuple[str, str]]:
|
||||||
return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES]
|
return [route for route in routes_declarees(app) if route not in ROUTES_PUBLIQUES]
|
||||||
|
|
||||||
|
|
||||||
def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None:
|
def test_the_public_allow_list_has_no_stale_entry(app: FastAPI) -> None:
|
||||||
declarees = set(routes_declarees(app)) | HORS_SCHEMA
|
declarees = set(routes_declarees(app)) | {("GET", "/metrics")}
|
||||||
|
|
||||||
inconnues = ROUTES_PUBLIQUES - declarees
|
inconnues = ROUTES_PUBLIQUES - declarees
|
||||||
|
|
||||||
assert inconnues == set()
|
assert inconnues == set()
|
||||||
|
|
||||||
|
|
||||||
# Sans lui, une route ajoutée sans être classée n'est vue par aucun test de rôle : elle hérite
|
|
||||||
# du seul contrôle anonyme, et une garde posée au mauvais niveau passe inaperçue.
|
|
||||||
def test_every_declared_route_is_classified(app: FastAPI) -> None:
|
|
||||||
classees = ROUTES_PUBLIQUES | ROUTE_COOKIE | ROUTES_SANS_ROLE | set(ROLE_MINIMUM)
|
|
||||||
|
|
||||||
non_classees = set(routes_declarees(app)) - classees
|
|
||||||
fantomes = classees - set(routes_declarees(app)) - HORS_SCHEMA
|
|
||||||
|
|
||||||
assert non_classees == set(), "classer la route dans tests/api/acces.py"
|
|
||||||
assert fantomes == set(), "entrée morte : la route n'existe plus sous ce chemin"
|
|
||||||
|
|
||||||
|
|
||||||
def test_the_four_classes_of_routes_stay_disjoint() -> None:
|
|
||||||
classes = [ROUTES_PUBLIQUES, ROUTE_COOKIE, ROUTES_SANS_ROLE, frozenset(ROLE_MINIMUM)]
|
|
||||||
|
|
||||||
for rang, classe in enumerate(classes):
|
|
||||||
for autre in classes[rang + 1 :]:
|
|
||||||
assert classe & autre == frozenset()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public(
|
async def test_every_route_rejects_an_anonymous_caller_unless_explicitly_public(
|
||||||
app: FastAPI, client: AsyncClient
|
app: FastAPI, client: AsyncClient
|
||||||
) -> None:
|
) -> None:
|
||||||
ouvertes: list[tuple[str, str, int]] = []
|
ouvertes: list[tuple[str, str, int]] = []
|
||||||
|
|
||||||
for methode, chemin in routes_protegees(app):
|
for methode, chemin in routes_protegees(app):
|
||||||
response = await client.request(methode, chemin_concret(chemin), json={})
|
concret = chemin.replace("{user_id}", VALEURS_DE_SUBSTITUTION)
|
||||||
|
response = await client.request(methode, concret, json={})
|
||||||
if response.status_code not in STATUTS_DE_REFUS:
|
if response.status_code not in STATUTS_DE_REFUS:
|
||||||
ouvertes.append((methode, chemin, response.status_code))
|
ouvertes.append((methode, chemin, response.status_code))
|
||||||
|
|
||||||
@@ -101,5 +95,4 @@ async def test_the_documentation_routes_are_public_by_design(
|
|||||||
app: FastAPI, client: AsyncClient, chemin: str
|
app: FastAPI, client: AsyncClient, chemin: str
|
||||||
) -> None:
|
) -> None:
|
||||||
response = await client.get(chemin)
|
response = await client.get(chemin)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
sonar.projectKey=ProjetPiscine_EnerVision
|
|
||||||
sonar.organization=groupe3-ener-vision
|
|
||||||
sonar.sourceEncoding=UTF-8
|
|
||||||
|
|
||||||
# Dossier contenant le code source
|
|
||||||
sonar.sources=apps/frontend/src,apps/backend/app
|
|
||||||
sonar.tests=apps/backend/tests
|
|
||||||
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
|
||||||
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
|
|
||||||
|
|
||||||
# Chemin vers le rapport de couverture de code
|
|
||||||
# Fichier généré par Vitest
|
|
||||||
# Chemin vers le rapport de couverture de code
|
|
||||||
# Fichier généré par Vitest
|
|
||||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
|
||||||
sonar.python.coverage.reportPaths=apps/backend/cov.info
|
|
||||||
@@ -21,6 +21,15 @@ export const routes: Routes = [
|
|||||||
path: 'sites/:siteId',
|
path: 'sites/:siteId',
|
||||||
canActivate: [authGuard],
|
canActivate: [authGuard],
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/sites/site-detail/site-detail').then((m) => m.SiteDetail),
|
import('./features/sites/site-detail-placeholder/site-detail-placeholder').then(
|
||||||
|
(m) => m.SiteDetailPlaceholder,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'monitoring/sensors',
|
||||||
|
canActivate: [authGuard],
|
||||||
|
data: { role: 'admin' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/monitoring/sensor-status/sensor-status').then((m) => m.SensorStatusView),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { provideHttpClient } from '@angular/common/http';
|
|
||||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
|
||||||
import { ReadingsService } from './readings.service';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
|
|
||||||
describe('ReadingsService', () => {
|
|
||||||
let service: ReadingsService;
|
|
||||||
let httpMock: HttpTestingController;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [provideHttpClient(), provideHttpClientTesting()],
|
|
||||||
});
|
|
||||||
service = TestBed.inject(ReadingsService);
|
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => httpMock.verify());
|
|
||||||
|
|
||||||
it("demande l'historique du site avec la fenêtre temporelle donnée", () => {
|
|
||||||
let result: unknown;
|
|
||||||
service
|
|
||||||
.getHistory('SITE001', '2026-09-16T00:00:00Z', '2026-09-17T00:00:00Z')
|
|
||||||
.subscribe((r) => (result = r));
|
|
||||||
|
|
||||||
const req = httpMock.expectOne(
|
|
||||||
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
|
|
||||||
);
|
|
||||||
expect(req.request.params.get('site_id')).toBe('SITE001');
|
|
||||||
expect(req.request.params.get('start')).toBe('2026-09-16T00:00:00Z');
|
|
||||||
expect(req.request.params.get('end')).toBe('2026-09-17T00:00:00Z');
|
|
||||||
|
|
||||||
req.flush([{ reading_id: 1, site_id: 'SITE001', consumption_kw: 12.5 }]);
|
|
||||||
|
|
||||||
expect((result as unknown[]).length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ne pose pas de paramètres start/end quand ils sont omis', () => {
|
|
||||||
service.getHistory('SITE001').subscribe();
|
|
||||||
|
|
||||||
const req = httpMock.expectOne(
|
|
||||||
(r) => r.url === `${environment.apiUrl}/readings` && r.method === 'GET',
|
|
||||||
);
|
|
||||||
expect(req.request.params.has('start')).toBe(false);
|
|
||||||
expect(req.request.params.has('end')).toBe(false);
|
|
||||||
|
|
||||||
req.flush([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Service, inject } from '@angular/core';
|
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { Reading } from '../../shared/models/reading.model';
|
|
||||||
|
|
||||||
@Service()
|
|
||||||
export class ReadingsService {
|
|
||||||
private http = inject(HttpClient);
|
|
||||||
|
|
||||||
getHistory(siteId: string, start?: string, end?: string) {
|
|
||||||
let params = new HttpParams().set('site_id', siteId);
|
|
||||||
if (start) {
|
|
||||||
params = params.set('start', start);
|
|
||||||
}
|
|
||||||
if (end) {
|
|
||||||
params = params.set('end', end);
|
|
||||||
}
|
|
||||||
return this.http.get<Reading[]>(`${environment.apiUrl}/readings`, { params });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { provideHttpClient } from '@angular/common/http';
|
||||||
|
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||||
|
import { SensorsService } from './sensors.service';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
|
||||||
|
describe('SensorsService', () => {
|
||||||
|
let service: SensorsService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [provideHttpClient(), provideHttpClientTesting()],
|
||||||
|
});
|
||||||
|
service = TestBed.inject(SensorsService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => httpMock.verify());
|
||||||
|
|
||||||
|
it("appelle l'endpoint /sensors/status et retourne la réponse", () => {
|
||||||
|
let result: unknown;
|
||||||
|
service.getStatus().subscribe((r) => (result = r));
|
||||||
|
|
||||||
|
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/status`);
|
||||||
|
expect(req.request.method).toBe('GET');
|
||||||
|
|
||||||
|
req.flush({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [
|
||||||
|
{
|
||||||
|
site_id: 'SITE001',
|
||||||
|
site_name: 'Test',
|
||||||
|
overall: 'ok',
|
||||||
|
sensors: {
|
||||||
|
consumption: { status: 'ok', since: null },
|
||||||
|
electrical: { status: 'ok', since: null },
|
||||||
|
temperature: { status: 'ok', since: null },
|
||||||
|
humidity: { status: 'ok', since: null },
|
||||||
|
network: { status: 'ok', since: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((result as { sites: unknown[] }).sites.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Service, inject } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { environment } from '../../../environments/environment';
|
||||||
|
import {SensorStatusResponse} from '../../shared/models/sensor-status.model';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class SensorsService {
|
||||||
|
private http = inject(HttpClient);
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return this.http.get<SensorStatusResponse>(`${environment.apiUrl}/sensors/status`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,50 +38,4 @@ describe('SitesService', () => {
|
|||||||
|
|
||||||
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
|
expect((result as { site_id: string }[])[0].site_id).toBe('SITE001');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appelle le bon endpoint et retourne un site', () => {
|
|
||||||
let result: unknown;
|
|
||||||
service.getSite('SITE001').subscribe((r) => (result = r));
|
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001`);
|
|
||||||
expect(req.request.method).toBe('GET');
|
|
||||||
|
|
||||||
req.flush({
|
|
||||||
site_id: 'SITE001',
|
|
||||||
site_name: 'Site 1',
|
|
||||||
site_type: 'industriel',
|
|
||||||
location: 'Nantes',
|
|
||||||
capacity_kw: 500,
|
|
||||||
status: 'actif',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((result as { site_id: string }).site_id).toBe('SITE001');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('appelle le bon endpoint et retourne la mesure courante du site', () => {
|
|
||||||
let result: unknown;
|
|
||||||
service.getCurrent('SITE001').subscribe((r) => (result = r));
|
|
||||||
|
|
||||||
const req = httpMock.expectOne(`${environment.apiUrl}/sites/SITE001/current`);
|
|
||||||
expect(req.request.method).toBe('GET');
|
|
||||||
|
|
||||||
req.flush({
|
|
||||||
timestamp: '2026-09-17T10:00:00Z',
|
|
||||||
site_id: 'SITE001',
|
|
||||||
site_type: 'industriel',
|
|
||||||
consumption_kw: 120,
|
|
||||||
consumption_kwh: null,
|
|
||||||
voltage_v: null,
|
|
||||||
current_a: null,
|
|
||||||
power_factor: null,
|
|
||||||
temperature_celsius: 22,
|
|
||||||
humidity_percent: 55,
|
|
||||||
null_reasons: ['electrical_sensor_failure'],
|
|
||||||
data_quality: 'partial',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect((result as { null_reasons: string[] }).null_reasons).toEqual([
|
|
||||||
'electrical_sensor_failure',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Service, inject } from '@angular/core';
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { environment } from '../../../environments/environment';
|
import { environment } from '../../../environments/environment';
|
||||||
import { Site } from '../../shared/models/site.model';
|
import { Site } from '../../shared/models/site.model';
|
||||||
import { SiteCurrent } from '../../shared/models/site-current.model';
|
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class SitesService {
|
export class SitesService {
|
||||||
@@ -11,12 +10,4 @@ export class SitesService {
|
|||||||
getSites() {
|
getSites() {
|
||||||
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
|
return this.http.get<Site[]>(`${environment.apiUrl}/sites`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSite(siteId: string) {
|
|
||||||
return this.http.get<Site>(`${environment.apiUrl}/sites/${siteId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrent(siteId: string) {
|
|
||||||
return this.http.get<SiteCurrent>(`${environment.apiUrl}/sites/${siteId}/current`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard__actions">
|
<div class="dashboard__actions">
|
||||||
|
@if (auth.principal()?.role === 'admin') {
|
||||||
|
<a routerLink="/monitoring/sensors" class="ev-link">Supervision des capteurs</a>
|
||||||
|
}
|
||||||
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
<a routerLink="/sites" class="ev-link">Voir les sites</a>
|
||||||
<ev-button
|
<ev-button
|
||||||
class="logout-button"
|
class="logout-button"
|
||||||
|
|||||||
@@ -68,6 +68,15 @@ h2 {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card--link {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.card__label {
|
.card__label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
|||||||
@@ -101,8 +101,11 @@ describe('Dashboard', () => {
|
|||||||
it('appelle logout et redirige vers /login au clic sur le bouton de déconnexion', () => {
|
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 statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||||
const authMock = { logout: vi.fn().mockReturnValue(of(undefined)), clearSession: vi.fn() };
|
const authMock = {
|
||||||
|
logout: vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
|
};
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -128,9 +131,10 @@ describe('Dashboard', () => {
|
|||||||
it('déconnecte localement et redirige vers /login même si logout échoue côté réseau', () => {
|
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 statsMock = { getSummary: vi.fn().mockReturnValue(of({ total_sites: 7, sites: [] })) };
|
||||||
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
const alertsMock = { getAlerts: vi.fn().mockReturnValue(of([])) };
|
||||||
const authMock = {
|
const authMock = {
|
||||||
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
logout: vi.fn().mockReturnValue(throwError(() => new Error('réseau indisponible'))),
|
||||||
clearSession: vi.fn(),
|
clearSession: vi.fn(),
|
||||||
|
principal: vi.fn().mockReturnValue({ role: 'admin' }),
|
||||||
};
|
};
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [Dashboard],
|
imports: [Dashboard],
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const TON_PAR_SEVERITE: Record<AlertSeverity, BadgeTone> = {
|
|||||||
export class Dashboard implements OnInit {
|
export class Dashboard implements OnInit {
|
||||||
private statsService = inject(StatsService);
|
private statsService = inject(StatsService);
|
||||||
private alertsService = inject(AlertsService);
|
private alertsService = inject(AlertsService);
|
||||||
private auth = inject(AuthService);
|
public auth = inject(AuthService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private destroyRef = inject(DestroyRef);
|
private destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<div class="sensor-status">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="sensor-status__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="sensor-status__logo" />
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1>Supervision des capteurs</h1>
|
||||||
|
<p class="sensor-status__subtitle">État de santé par capteur et par site</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (error(); as message) {
|
||||||
|
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (data(); as d) {
|
||||||
|
<div class="sites-grid">
|
||||||
|
@for (site of d.sites; track site.site_id) {
|
||||||
|
<ev-card class="site-card">
|
||||||
|
<div class="site-card__header">
|
||||||
|
<span class="site-card__name">{{ site.site_name }}</span>
|
||||||
|
<ev-badge [tone]="badgeToneForOverall(site.overall)">{{ site.overall }}</ev-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="sensor-list">
|
||||||
|
@for (entry of sensorEntries; track entry[0]) {
|
||||||
|
<li class="sensor-item">
|
||||||
|
<span
|
||||||
|
class="sensor-dot"
|
||||||
|
[class]="'sensor-dot--' + sensorOf(site.sensors, entry[0]).status"
|
||||||
|
></span>
|
||||||
|
<span class="sensor-item__label">{{ entry[1] }}</span>
|
||||||
|
@if (sensorOf(site.sensors, entry[0]).status === 'failing') {
|
||||||
|
<span class="sensor-item__since">
|
||||||
|
depuis {{ sensorOf(site.sensors, entry[0]).since | date: 'short' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</ev-card>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-status__subtitle {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner-error {
|
||||||
|
display: block;
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sites-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-card__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--ok {
|
||||||
|
background: var(--color-success);
|
||||||
|
}
|
||||||
|
&--failing {
|
||||||
|
background: var(--color-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item__label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensor-item__since {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { of, throwError } from 'rxjs';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
import { SensorStatusView } from './sensor-status';
|
||||||
|
import { SensorsService } from '../../../core/services/sensors.service';
|
||||||
|
import { SiteSensors } from '../../../shared/models/sensor-status.model';
|
||||||
|
import {provideRouter} from '@angular/router';
|
||||||
|
|
||||||
|
const OK_SENSORS: SiteSensors = {
|
||||||
|
consumption: { status: 'ok', since: null },
|
||||||
|
electrical: { status: 'ok', since: null },
|
||||||
|
temperature: { status: 'ok', since: null },
|
||||||
|
humidity: { status: 'ok', since: null },
|
||||||
|
network: { status: 'ok', since: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SensorStatusView', () => {
|
||||||
|
let sensorsMock: { getStatus: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sensorsMock = { getStatus: vi.fn() };
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SensorStatusView],
|
||||||
|
providers: [
|
||||||
|
{ provide: SensorsService, useValue: sensorsMock },
|
||||||
|
provideRouter([]),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('charge et affiche les données au démarrage', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(
|
||||||
|
of({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [
|
||||||
|
{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'ok', sensors: OK_SENSORS },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.data()?.sites.length).toBe(1);
|
||||||
|
expect(fixture.componentInstance.error()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Bureau Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("affiche un message d'erreur si l'appel échoue", () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(throwError(() => new Error('boom')));
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.error()).toBe(
|
||||||
|
'État des capteurs indisponible, réessayez plus tard.'
|
||||||
|
);
|
||||||
|
expect(fixture.componentInstance.data()).toBeNull();
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('État des capteurs indisponible');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associe le bon ton de badge à chaque statut global', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component.badgeToneForOverall('ok')).toBe('success');
|
||||||
|
expect(component.badgeToneForOverall('degraded')).toBe('warning');
|
||||||
|
expect(component.badgeToneForOverall('critical')).toBe('critical');
|
||||||
|
expect(component.badgeToneForOverall('inconnu')).toBe('neutral');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne le bon diagnostic via sensorOf', () => {
|
||||||
|
sensorsMock.getStatus.mockReturnValue(of({ timestamp: '2026-09-18T08:00:00', sites: [] }));
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component.sensorOf(OK_SENSORS, 'temperature')).toEqual({ status: 'ok', since: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('affiche la date depuis quand un capteur est en panne', () => {
|
||||||
|
const sensors: SiteSensors = {
|
||||||
|
...OK_SENSORS,
|
||||||
|
temperature: { status: 'failing', since: '2026-09-18T08:00:00' },
|
||||||
|
};
|
||||||
|
sensorsMock.getStatus.mockReturnValue(
|
||||||
|
of({
|
||||||
|
timestamp: '2026-09-18T08:00:00',
|
||||||
|
sites: [{ site_id: 'SITE001', site_name: 'Bureau Test', overall: 'degraded', sensors }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SensorStatusView);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('depuis');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { catchError, EMPTY, Observable } from 'rxjs';
|
||||||
|
import {Badge, BadgeTone} from '../../../shared/components/ui/badge/badge';
|
||||||
|
import {Card} from '../../../shared/components/ui/card/card';
|
||||||
|
import {Alert} from '../../../shared/components/ui/alert/alert';
|
||||||
|
import {Brand} from '../../../shared/components/ui/brand/brand';
|
||||||
|
import {SensorsService} from '../../../core/services/sensors.service';
|
||||||
|
import {SensorDiagnostic, SensorStatusResponse} from '../../../shared/models/sensor-status.model';
|
||||||
|
import { DatePipe } from '@angular/common';
|
||||||
|
|
||||||
|
const UNAVAILABLE_MESSAGE = 'État des capteurs indisponible, réessayez plus tard.';
|
||||||
|
|
||||||
|
const SENSOR_LABELS: Record<string, string> = {
|
||||||
|
consumption: 'Consommation',
|
||||||
|
electrical: 'Électrique',
|
||||||
|
temperature: 'Température',
|
||||||
|
humidity: 'Humidité',
|
||||||
|
network: 'Réseau',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TON_PAR_OVERALL: Record<string, BadgeTone> = {
|
||||||
|
ok: 'success',
|
||||||
|
degraded: 'warning',
|
||||||
|
critical: 'critical',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-sensor-status',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, Card, Alert, Badge, Brand, DatePipe],
|
||||||
|
templateUrl: './sensor-status.html',
|
||||||
|
styleUrl: './sensor-status.scss',
|
||||||
|
})
|
||||||
|
export class SensorStatusView implements OnInit {
|
||||||
|
private sensorsService = inject(SensorsService);
|
||||||
|
|
||||||
|
data = signal<SensorStatusResponse | null>(null);
|
||||||
|
error = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly sensorEntries = Object.entries(SENSOR_LABELS);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.sensorsService
|
||||||
|
.getStatus()
|
||||||
|
.pipe(catchError(() => this.reportUnavailable()))
|
||||||
|
.subscribe((response) => {
|
||||||
|
this.error.set(null);
|
||||||
|
this.data.set(response);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sensorOf(sensors: Record<string, SensorDiagnostic>, key: string): SensorDiagnostic {
|
||||||
|
return sensors[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeToneForOverall(overall: string): BadgeTone {
|
||||||
|
return TON_PAR_OVERALL[overall] ?? 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportUnavailable(): Observable<never> {
|
||||||
|
this.error.set(UNAVAILABLE_MESSAGE);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
<div class="site-detail-placeholder">
|
||||||
|
<nav class="ev-breadcrumb">
|
||||||
|
<a routerLink="/dashboard">Tableau de bord</a>
|
||||||
|
<span>/</span>
|
||||||
|
<a routerLink="/sites">Sites</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<header class="site-detail-placeholder__header">
|
||||||
|
<a routerLink="/dashboard" class="ev-brand-link">
|
||||||
|
<ev-brand class="site-detail-placeholder__logo" />
|
||||||
|
</a>
|
||||||
|
<h1>Site {{ siteId() }}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ev-card>
|
||||||
|
<p>Le détail de ce site est à venir (voir issue #51).</p>
|
||||||
|
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
||||||
|
</ev-card>
|
||||||
|
</div>
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: var(--color-text);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-detail-placeholder__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-detail-placeholder__logo {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ev-card p {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
|
import { SiteDetailPlaceholder } from './site-detail-placeholder';
|
||||||
|
|
||||||
|
describe('SiteDetailPlaceholder', () => {
|
||||||
|
it("affiche l'identifiant du site depuis la route", () => {
|
||||||
|
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SiteDetailPlaceholder],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('SITE001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('met à jour l\'affichage quand le paramètre change sans recréer le composant', () => {
|
||||||
|
const paramMap = new BehaviorSubject(convertToParamMap({ siteId: 'SITE001' }));
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [SiteDetailPlaceholder],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: ActivatedRoute, useValue: { paramMap } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(SiteDetailPlaceholder);
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('SITE002');
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('SITE001');
|
||||||
|
});
|
||||||
|
});
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { Card } from '../../../shared/components/ui/card/card';
|
||||||
|
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-site-detail-placeholder',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, Card, Brand],
|
||||||
|
templateUrl: './site-detail-placeholder.html',
|
||||||
|
styleUrl: './site-detail-placeholder.scss',
|
||||||
|
})
|
||||||
|
export class SiteDetailPlaceholder {
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId'))));
|
||||||
|
}
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<div class="site-detail">
|
|
||||||
<nav class="ev-breadcrumb">
|
|
||||||
<a routerLink="/dashboard">Tableau de bord</a>
|
|
||||||
<span>/</span>
|
|
||||||
<a routerLink="/sites">Sites</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<header class="site-detail__header">
|
|
||||||
<a routerLink="/dashboard" class="ev-brand-link">
|
|
||||||
<ev-brand class="site-detail__logo" />
|
|
||||||
</a>
|
|
||||||
<div>
|
|
||||||
<h1>{{ site()?.site_name ?? siteId() }}</h1>
|
|
||||||
@if (site(); as s) {
|
|
||||||
<p class="site-detail__subtitle">
|
|
||||||
{{ s.site_type }} · {{ s.location || 'Localisation inconnue' }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="site-detail__badges">
|
|
||||||
@if (site(); as s) {
|
|
||||||
<ev-badge [tone]="badgeToneForStatus(s.status)">{{ s.status ?? '-' }}</ev-badge>
|
|
||||||
}
|
|
||||||
@if (hasMeasurement() && qualityLabel(); as label) {
|
|
||||||
<ev-badge [tone]="qualityTone()">{{ label }}</ev-badge>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (error(); as message) {
|
|
||||||
<ev-alert severity="danger" class="banner-error">{{ message }}</ev-alert>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (site(); as s) {
|
|
||||||
@if (hasMeasurement()) {
|
|
||||||
<section class="overview">
|
|
||||||
<ev-card class="card card--gauge">
|
|
||||||
<span class="card__label">Consommation vs capacité</span>
|
|
||||||
@let consumption = consumptionKw();
|
|
||||||
@if (consumption !== null) {
|
|
||||||
<app-consumption-gauge [consumption]="consumption" [capacity]="s.capacity_kw ?? 0" />
|
|
||||||
<span class="card__value">
|
|
||||||
{{ consumptionLabel() }} / {{ s.capacity_kw ?? '-' }} kW
|
|
||||||
</span>
|
|
||||||
} @else {
|
|
||||||
<p class="card__unavailable">
|
|
||||||
Consommation indisponible
|
|
||||||
<span class="metric__reason">({{ consumptionReason() }})</span>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</ev-card>
|
|
||||||
|
|
||||||
<ev-card class="metrics-card">
|
|
||||||
<span class="card__label">Mesure instantanée</span>
|
|
||||||
<dl class="metrics-grid">
|
|
||||||
@for (metric of metrics(); track metric.key) {
|
|
||||||
<div class="metric">
|
|
||||||
<dt>{{ metric.label }}</dt>
|
|
||||||
@if (metric.value !== null) {
|
|
||||||
<dd>{{ metric.value }}</dd>
|
|
||||||
} @else {
|
|
||||||
<dd class="metric__unavailable">
|
|
||||||
Indisponible
|
|
||||||
<span class="metric__reason">({{ metric.reason }})</span>
|
|
||||||
</dd>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</dl>
|
|
||||||
</ev-card>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@if (history().length > 0) {
|
|
||||||
<section class="chart-section">
|
|
||||||
<h2>Historique de consommation</h2>
|
|
||||||
<app-reading-history-chart [readings]="history()" />
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<ev-alert severity="warning" class="banner-empty">{{ noMeasurementMessage }}</ev-alert>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<a routerLink="/sites" class="ev-link">Retour aux sites</a>
|
|
||||||
</div>
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
color: var(--color-text);
|
|
||||||
padding: 2.5rem 2rem;
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-detail__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.85rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-detail__logo {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-detail__subtitle {
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-detail__badges {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-error {
|
|
||||||
display: block;
|
|
||||||
margin: 0 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-empty {
|
|
||||||
display: block;
|
|
||||||
margin: 0 0 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overview {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 1.25rem;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card--gauge {
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__value {
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__unavailable {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 0.75rem 1.5rem;
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
dt {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric__unavailable {
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric__reason {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-section {
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
import { BehaviorSubject, of, throwError } from 'rxjs';
|
|
||||||
import { SiteDetail } from './site-detail';
|
|
||||||
import { SitesService } from '../../../core/services/sites.service';
|
|
||||||
import { ReadingsService } from '../../../core/services/readings.service';
|
|
||||||
|
|
||||||
const SITE = {
|
|
||||||
site_id: 'SITE001',
|
|
||||||
site_name: 'Site 1',
|
|
||||||
site_type: 'industriel',
|
|
||||||
location: 'Nantes',
|
|
||||||
capacity_kw: 500,
|
|
||||||
status: 'actif',
|
|
||||||
};
|
|
||||||
|
|
||||||
const CURRENT_COMPLET = {
|
|
||||||
timestamp: '2026-09-17T10:00:00Z',
|
|
||||||
site_id: 'SITE001',
|
|
||||||
site_type: 'industriel',
|
|
||||||
consumption_kw: 120,
|
|
||||||
consumption_kwh: null,
|
|
||||||
voltage_v: 230,
|
|
||||||
current_a: 12,
|
|
||||||
power_factor: 0.95,
|
|
||||||
temperature_celsius: 22,
|
|
||||||
humidity_percent: 55,
|
|
||||||
null_reasons: [] as string[],
|
|
||||||
data_quality: 'good' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SANS_MESURE = {
|
|
||||||
...CURRENT_COMPLET,
|
|
||||||
timestamp: null,
|
|
||||||
consumption_kw: null,
|
|
||||||
voltage_v: null,
|
|
||||||
current_a: null,
|
|
||||||
power_factor: null,
|
|
||||||
temperature_celsius: null,
|
|
||||||
humidity_percent: null,
|
|
||||||
data_quality: 'critical' as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
const LECTURE = {
|
|
||||||
reading_id: 1,
|
|
||||||
site_id: 'SITE001',
|
|
||||||
timestamp: '2026-09-17T09:00:00Z',
|
|
||||||
source: 'api_history' as const,
|
|
||||||
consumption_kw: 118,
|
|
||||||
consumption_kwh: null,
|
|
||||||
consumption_euros: null,
|
|
||||||
voltage_v: 230,
|
|
||||||
current_a: 12,
|
|
||||||
power_factor: 0.95,
|
|
||||||
temperature_celsius: 22,
|
|
||||||
humidity_percent: 55,
|
|
||||||
solar_irradiance_wm2: null,
|
|
||||||
is_working_hours: true,
|
|
||||||
data_quality: 'good' as const,
|
|
||||||
null_reasons: null,
|
|
||||||
imputed_values: null,
|
|
||||||
imputation_method: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
function setup(
|
|
||||||
siteId: string,
|
|
||||||
sitesMock: Partial<SitesService>,
|
|
||||||
readingsMock: Partial<ReadingsService>,
|
|
||||||
) {
|
|
||||||
const paramMap = new BehaviorSubject(convertToParamMap({ siteId }));
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [SiteDetail],
|
|
||||||
providers: [
|
|
||||||
provideRouter([]),
|
|
||||||
{ provide: ActivatedRoute, useValue: { paramMap } },
|
|
||||||
{ provide: SitesService, useValue: sitesMock },
|
|
||||||
{ provide: ReadingsService, useValue: readingsMock },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return { fixture: TestBed.createComponent(SiteDetail), paramMap };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SiteDetail', () => {
|
|
||||||
it('charge le site, la mesure courante et son historique au démarrage', () => {
|
|
||||||
const { fixture } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{
|
|
||||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
|
||||||
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
|
||||||
},
|
|
||||||
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
|
|
||||||
expect(fixture.componentInstance.current()?.consumption_kw).toBe(120);
|
|
||||||
expect(fixture.componentInstance.history().length).toBe(1);
|
|
||||||
expect(fixture.componentInstance.error()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("signale l'indisponibilité quand un des appels échoue", () => {
|
|
||||||
const { fixture } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{
|
|
||||||
getSite: vi.fn().mockReturnValue(throwError(() => new Error('nope'))),
|
|
||||||
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
|
||||||
},
|
|
||||||
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
|
||||||
expect(fixture.componentInstance.site()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('efface les données du site précédent quand le chargement du suivant échoue', () => {
|
|
||||||
const getSite = vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValueOnce(of(SITE))
|
|
||||||
.mockReturnValueOnce(throwError(() => new Error('404')));
|
|
||||||
const { fixture, paramMap } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
|
|
||||||
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
expect(fixture.componentInstance.site()?.site_id).toBe('SITE001');
|
|
||||||
|
|
||||||
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.error()).not.toBeNull();
|
|
||||||
expect(fixture.componentInstance.site()).toBeNull();
|
|
||||||
expect(fixture.componentInstance.current()).toBeNull();
|
|
||||||
expect(fixture.componentInstance.history()).toEqual([]);
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain('Site 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('interroge le site et sa mesure courante en parallèle', () => {
|
|
||||||
const getSite = vi.fn().mockReturnValue(of(SITE));
|
|
||||||
const getCurrent = vi.fn().mockReturnValue(of(CURRENT_COMPLET));
|
|
||||||
const { fixture } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{ getSite, getCurrent },
|
|
||||||
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(getSite).toHaveBeenCalledWith('SITE001');
|
|
||||||
expect(getCurrent).toHaveBeenCalledWith('SITE001');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('signale la panne du capteur de consommation au lieu de tracer une jauge à zéro', () => {
|
|
||||||
const sansConsommation = {
|
|
||||||
...CURRENT_COMPLET,
|
|
||||||
consumption_kw: null,
|
|
||||||
null_reasons: ['consumption_sensor_failure'],
|
|
||||||
data_quality: 'partial' as const,
|
|
||||||
};
|
|
||||||
const { fixture } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{
|
|
||||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
|
||||||
getCurrent: vi.fn().mockReturnValue(of(sansConsommation)),
|
|
||||||
},
|
|
||||||
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.consumptionKw()).toBeNull();
|
|
||||||
expect(fixture.componentInstance.consumptionReason()).toBe('capteur de consommation en panne');
|
|
||||||
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).toBeNull();
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Consommation indisponible');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('trace la jauge pour une consommation nulle réellement mesurée', () => {
|
|
||||||
const { fixture } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{
|
|
||||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
|
||||||
getCurrent: vi.fn().mockReturnValue(of({ ...CURRENT_COMPLET, consumption_kw: 0 })),
|
|
||||||
},
|
|
||||||
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(fixture.componentInstance.consumptionLabel()).toBe('0.0 kW');
|
|
||||||
expect(fixture.nativeElement.querySelector('app-consumption-gauge')).not.toBeNull();
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain('Consommation indisponible');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('affiche explicitement les champs null avec leur raison plutôt que de les masquer', () => {
|
|
||||||
const partielle = {
|
|
||||||
...CURRENT_COMPLET,
|
|
||||||
voltage_v: null,
|
|
||||||
current_a: null,
|
|
||||||
power_factor: null,
|
|
||||||
null_reasons: ['electrical_sensor_failure'],
|
|
||||||
data_quality: 'partial' as const,
|
|
||||||
};
|
|
||||||
const { fixture } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{
|
|
||||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
|
||||||
getCurrent: vi.fn().mockReturnValue(of(partielle)),
|
|
||||||
},
|
|
||||||
{ getHistory: vi.fn().mockReturnValue(of([LECTURE])) },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const tension = fixture.componentInstance.metrics().find((m) => m.key === 'voltage_v');
|
|
||||||
expect(tension?.value).toBeNull();
|
|
||||||
expect(tension?.reason).toBe('capteur électrique en panne');
|
|
||||||
|
|
||||||
const texte = fixture.nativeElement.textContent;
|
|
||||||
expect(texte).toContain('Indisponible');
|
|
||||||
expect(texte).toContain('capteur électrique en panne');
|
|
||||||
expect(texte).toContain('Données partielles');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recharge les données quand le paramètre de route siteId change', () => {
|
|
||||||
const getSite = vi.fn().mockReturnValue(of(SITE));
|
|
||||||
const { fixture, paramMap } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{ getSite, getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)) },
|
|
||||||
{ getHistory: vi.fn().mockReturnValue(of([])) },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
paramMap.next(convertToParamMap({ siteId: 'SITE002' }));
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(getSite).toHaveBeenCalledWith('SITE002');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ancre la fenêtre d'historique sur la dernière mesure connue plutôt que sur l'horloge", () => {
|
|
||||||
const getHistory = vi.fn().mockReturnValue(of([]));
|
|
||||||
const { fixture } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{
|
|
||||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
|
||||||
getCurrent: vi.fn().mockReturnValue(of(CURRENT_COMPLET)),
|
|
||||||
},
|
|
||||||
{ getHistory },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(getHistory).toHaveBeenCalledWith(
|
|
||||||
'SITE001',
|
|
||||||
'2026-09-16T10:00:00.000Z',
|
|
||||||
'2026-09-17T10:00:00Z',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("annonce l'absence de mesure sans interroger l'historique quand timestamp est null", () => {
|
|
||||||
const getHistory = vi.fn().mockReturnValue(of([]));
|
|
||||||
const { fixture } = setup(
|
|
||||||
'SITE001',
|
|
||||||
{
|
|
||||||
getSite: vi.fn().mockReturnValue(of(SITE)),
|
|
||||||
getCurrent: vi.fn().mockReturnValue(of(SANS_MESURE)),
|
|
||||||
},
|
|
||||||
{ getHistory },
|
|
||||||
);
|
|
||||||
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(getHistory).not.toHaveBeenCalled();
|
|
||||||
expect(fixture.componentInstance.hasMeasurement()).toBe(false);
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Aucune mesure remontée pour ce site.');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
|
||||||
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
|
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
|
||||||
import { catchError, EMPTY, filter, forkJoin, map, Observable, of, switchMap } from 'rxjs';
|
|
||||||
import { SitesService } from '../../../core/services/sites.service';
|
|
||||||
import { ReadingsService } from '../../../core/services/readings.service';
|
|
||||||
import { Site } from '../../../shared/models/site.model';
|
|
||||||
import { Reading, ReadingDataQuality } from '../../../shared/models/reading.model';
|
|
||||||
import { SiteCurrent } from '../../../shared/models/site-current.model';
|
|
||||||
import { Card } from '../../../shared/components/ui/card/card';
|
|
||||||
import { Alert } from '../../../shared/components/ui/alert/alert';
|
|
||||||
import { Badge, BadgeTone } from '../../../shared/components/ui/badge/badge';
|
|
||||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
|
||||||
import { ConsumptionGauge } from '../../../shared/components/consumption-gauge/consumption-gauge';
|
|
||||||
import { ReadingHistoryChart } from '../../../shared/components/reading-history-chart/reading-history-chart';
|
|
||||||
|
|
||||||
const UNAVAILABLE_MESSAGE = 'Détail du site indisponible, réessayez plus tard.';
|
|
||||||
const NO_MEASUREMENT_MESSAGE = 'Aucune mesure remontée pour ce site.';
|
|
||||||
const HISTORY_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const TON_PAR_STATUT: Record<string, BadgeTone> = {
|
|
||||||
actif: 'success',
|
|
||||||
maintenance: 'warning',
|
|
||||||
hors_service: 'danger',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TON_PAR_QUALITE: Record<ReadingDataQuality, BadgeTone> = {
|
|
||||||
good: 'success',
|
|
||||||
partial: 'warning',
|
|
||||||
degraded: 'danger',
|
|
||||||
critical: 'critical',
|
|
||||||
};
|
|
||||||
|
|
||||||
const LIBELLE_PAR_QUALITE: Record<ReadingDataQuality, string> = {
|
|
||||||
good: 'Données complètes',
|
|
||||||
partial: 'Données partielles',
|
|
||||||
degraded: 'Données dégradées',
|
|
||||||
critical: 'Données critiques',
|
|
||||||
};
|
|
||||||
|
|
||||||
type MetricKey =
|
|
||||||
| 'consumption_kw'
|
|
||||||
| 'voltage_v'
|
|
||||||
| 'current_a'
|
|
||||||
| 'power_factor'
|
|
||||||
| 'temperature_celsius'
|
|
||||||
| 'humidity_percent';
|
|
||||||
|
|
||||||
interface MetricDef {
|
|
||||||
key: MetricKey;
|
|
||||||
label: string;
|
|
||||||
format: (value: number) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONSUMPTION_DEF: MetricDef = {
|
|
||||||
key: 'consumption_kw',
|
|
||||||
label: 'Consommation',
|
|
||||||
format: (v) => `${v.toFixed(1)} kW`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const METRIC_DEFS: MetricDef[] = [
|
|
||||||
CONSUMPTION_DEF,
|
|
||||||
{ key: 'voltage_v', label: 'Tension', format: (v) => `${v.toFixed(1)} V` },
|
|
||||||
{ key: 'current_a', label: 'Courant', format: (v) => `${v.toFixed(1)} A` },
|
|
||||||
{ key: 'power_factor', label: 'Cos φ', format: (v) => v.toFixed(2) },
|
|
||||||
{ key: 'temperature_celsius', label: 'Température', format: (v) => `${v.toFixed(1)} °C` },
|
|
||||||
{ key: 'humidity_percent', label: 'Humidité', format: (v) => `${v.toFixed(0)} %` },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Contrainte : miroir de RAISON_VERS_CAPTEUR et CHAMPS_PAR_CAPTEUR (backend, services/sensor.py) ;
|
|
||||||
// `null_reasons` porte le code de panne du capteur, jamais le nom du champ resté vide.
|
|
||||||
const RAISONS_PAR_CHAMP: Record<MetricKey, string[]> = {
|
|
||||||
consumption_kw: ['consumption_sensor_failure', 'network_loss'],
|
|
||||||
voltage_v: ['electrical_sensor_failure', 'network_loss'],
|
|
||||||
current_a: ['electrical_sensor_failure', 'network_loss'],
|
|
||||||
power_factor: ['electrical_sensor_failure', 'network_loss'],
|
|
||||||
temperature_celsius: ['temperature_sensor_failure', 'network_loss'],
|
|
||||||
humidity_percent: ['humidity_sensor_failure', 'network_loss'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const LIBELLE_PAR_RAISON: Record<string, string> = {
|
|
||||||
consumption_sensor_failure: 'capteur de consommation en panne',
|
|
||||||
electrical_sensor_failure: 'capteur électrique en panne',
|
|
||||||
temperature_sensor_failure: 'capteur de température en panne',
|
|
||||||
humidity_sensor_failure: "capteur d'humidité en panne",
|
|
||||||
network_loss: 'perte réseau',
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface MetricView {
|
|
||||||
key: MetricKey;
|
|
||||||
label: string;
|
|
||||||
value: string | null;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-site-detail',
|
|
||||||
standalone: true,
|
|
||||||
imports: [RouterLink, Card, Alert, Badge, Brand, ConsumptionGauge, ReadingHistoryChart],
|
|
||||||
templateUrl: './site-detail.html',
|
|
||||||
styleUrl: './site-detail.scss',
|
|
||||||
})
|
|
||||||
export class SiteDetail {
|
|
||||||
private route = inject(ActivatedRoute);
|
|
||||||
private sitesService = inject(SitesService);
|
|
||||||
private readingsService = inject(ReadingsService);
|
|
||||||
private destroyRef = inject(DestroyRef);
|
|
||||||
|
|
||||||
readonly noMeasurementMessage = NO_MEASUREMENT_MESSAGE;
|
|
||||||
|
|
||||||
siteId = toSignal(this.route.paramMap.pipe(map((params) => params.get('siteId') ?? '')));
|
|
||||||
|
|
||||||
site = signal<Site | null>(null);
|
|
||||||
current = signal<SiteCurrent | null>(null);
|
|
||||||
history = signal<Reading[]>([]);
|
|
||||||
error = signal<string | null>(null);
|
|
||||||
|
|
||||||
hasMeasurement = computed(() => this.current()?.timestamp != null);
|
|
||||||
|
|
||||||
consumptionKw = computed(() => this.current()?.consumption_kw ?? null);
|
|
||||||
|
|
||||||
consumptionLabel = computed(() => {
|
|
||||||
const kw = this.consumptionKw();
|
|
||||||
return kw != null ? CONSUMPTION_DEF.format(kw) : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
consumptionReason = computed(() => this.reasonFor('consumption_kw', this.current()));
|
|
||||||
|
|
||||||
qualityLabel = computed(() => {
|
|
||||||
const quality = this.current()?.data_quality;
|
|
||||||
return quality ? LIBELLE_PAR_QUALITE[quality] : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
qualityTone = computed<BadgeTone>(() => {
|
|
||||||
const quality = this.current()?.data_quality;
|
|
||||||
return quality ? TON_PAR_QUALITE[quality] : 'neutral';
|
|
||||||
});
|
|
||||||
|
|
||||||
metrics = computed<MetricView[]>(() => {
|
|
||||||
const current = this.current();
|
|
||||||
return METRIC_DEFS.map((def) => {
|
|
||||||
const valeur = current ? current[def.key] : null;
|
|
||||||
return {
|
|
||||||
key: def.key,
|
|
||||||
label: def.label,
|
|
||||||
value: valeur != null ? def.format(valeur) : null,
|
|
||||||
reason: valeur == null ? this.reasonFor(def.key, current) : '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
toObservable(this.siteId)
|
|
||||||
.pipe(
|
|
||||||
filter((siteId): siteId is string => !!siteId),
|
|
||||||
// Piège : switchMap sur le flux externe annule le chargement en cours dès qu'un
|
|
||||||
// nouveau siteId arrive, sinon une réponse en retard peut écraser le site affiché.
|
|
||||||
switchMap((siteId) => this.load(siteId)),
|
|
||||||
takeUntilDestroyed(this.destroyRef),
|
|
||||||
)
|
|
||||||
.subscribe((result) => {
|
|
||||||
this.error.set(null);
|
|
||||||
this.site.set(result.site);
|
|
||||||
this.current.set(result.current);
|
|
||||||
this.history.set(result.history);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
badgeToneForStatus(status: string | null): BadgeTone {
|
|
||||||
return status ? (TON_PAR_STATUT[status] ?? 'neutral') : 'neutral';
|
|
||||||
}
|
|
||||||
|
|
||||||
private load(siteId: string) {
|
|
||||||
return forkJoin({
|
|
||||||
site: this.sitesService.getSite(siteId),
|
|
||||||
current: this.sitesService.getCurrent(siteId),
|
|
||||||
}).pipe(
|
|
||||||
switchMap(({ site, current }) =>
|
|
||||||
this.loadHistory(siteId, current).pipe(map((history) => ({ site, current, history }))),
|
|
||||||
),
|
|
||||||
catchError(() => this.reportUnavailable()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadHistory(siteId: string, current: SiteCurrent): Observable<Reading[]> {
|
|
||||||
// Piège : le jeu de données s'arrête bien avant « maintenant » ; ancrer la fenêtre sur la
|
|
||||||
// dernière mesure connue plutôt que sur l'horloge évite un historique systématiquement vide.
|
|
||||||
const end = current.timestamp;
|
|
||||||
if (end === null) {
|
|
||||||
return of([]);
|
|
||||||
}
|
|
||||||
const start = new Date(new Date(end).getTime() - HISTORY_WINDOW_MS).toISOString();
|
|
||||||
return this.readingsService.getHistory(siteId, start, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
private reasonFor(field: MetricKey, current: SiteCurrent | null): string {
|
|
||||||
const raisons = RAISONS_PAR_CHAMP[field];
|
|
||||||
const trouvees = (current?.null_reasons ?? [])
|
|
||||||
.filter((raison) => raisons.includes(raison))
|
|
||||||
.map((raison) => LIBELLE_PAR_RAISON[raison] ?? raison);
|
|
||||||
return trouvees.length > 0 ? trouvees.join(', ') : 'cause inconnue';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Piège : vider les signaux avec l'erreur, sinon la page garde le site précédemment chargé
|
|
||||||
// sous le bandeau et laisse lire les chiffres de A en croyant regarder B.
|
|
||||||
private reportUnavailable(): Observable<never> {
|
|
||||||
this.error.set(UNAVAILABLE_MESSAGE);
|
|
||||||
this.site.set(null);
|
|
||||||
this.current.set(null);
|
|
||||||
this.history.set([]);
|
|
||||||
return EMPTY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1
@@ -1 +0,0 @@
|
|||||||
<canvas #canvas></canvas>
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
height: 260px;
|
|
||||||
}
|
|
||||||
-101
@@ -1,101 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
import { Chart } from 'chart.js';
|
|
||||||
import { ReadingHistoryChart } from './reading-history-chart';
|
|
||||||
|
|
||||||
vi.mock('chart.js', () => {
|
|
||||||
class ChartMock {
|
|
||||||
static instances: ChartMock[] = [];
|
|
||||||
static register = vi.fn();
|
|
||||||
update = vi.fn();
|
|
||||||
destroy = vi.fn();
|
|
||||||
data: { labels?: unknown[]; datasets: Record<string, unknown>[] } = { datasets: [{}] };
|
|
||||||
constructor(_canvas: unknown, config?: { data?: ChartMock['data'] }) {
|
|
||||||
if (config?.data) {
|
|
||||||
this.data = config.data;
|
|
||||||
}
|
|
||||||
ChartMock.instances.push(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { Chart: ChartMock, registerables: [] };
|
|
||||||
});
|
|
||||||
|
|
||||||
type ChartDouble = {
|
|
||||||
destroy: ReturnType<typeof vi.fn>;
|
|
||||||
data: { labels?: unknown[]; datasets: Record<string, unknown>[] };
|
|
||||||
};
|
|
||||||
|
|
||||||
function lastChart(): ChartDouble | undefined {
|
|
||||||
return (Chart as unknown as { instances: ChartDouble[] }).instances.at(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const READING = {
|
|
||||||
reading_id: 1,
|
|
||||||
site_id: 'S1',
|
|
||||||
timestamp: '2026-09-17T10:00:00Z',
|
|
||||||
source: 'api_history' as const,
|
|
||||||
consumption_kw: 42,
|
|
||||||
consumption_kwh: null,
|
|
||||||
consumption_euros: null,
|
|
||||||
voltage_v: null,
|
|
||||||
current_a: null,
|
|
||||||
power_factor: null,
|
|
||||||
temperature_celsius: null,
|
|
||||||
humidity_percent: null,
|
|
||||||
solar_irradiance_wm2: null,
|
|
||||||
is_working_hours: null,
|
|
||||||
data_quality: 'good' as const,
|
|
||||||
null_reasons: null,
|
|
||||||
imputed_values: null,
|
|
||||||
imputation_method: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('ReadingHistoryChart', () => {
|
|
||||||
it('se crée sans erreur avec une liste de lectures valide', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
|
||||||
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
|
||||||
fixture.componentRef.setInput('readings', [READING]);
|
|
||||||
expect(() => fixture.detectChanges()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('met à jour le graphique quand les lectures changent après initialisation', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
|
||||||
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
|
||||||
fixture.componentRef.setInput('readings', [READING]);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
fixture.componentRef.setInput('readings', [
|
|
||||||
{ ...READING, reading_id: 2, consumption_kw: 60, data_quality: 'critical' as const },
|
|
||||||
]);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
expect(() => fixture.detectChanges()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trace du plus ancien au plus récent, quel que soit l'ordre reçu de l'API", () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
|
||||||
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
|
||||||
// L'API trie en timestamp décroissant : le composant doit rétablir la chronologie.
|
|
||||||
fixture.componentRef.setInput('readings', [
|
|
||||||
{ ...READING, reading_id: 2, timestamp: '2026-09-17T11:00:00Z', consumption_kw: 60 },
|
|
||||||
{ ...READING, reading_id: 1, timestamp: '2026-09-17T10:00:00Z', consumption_kw: 42 },
|
|
||||||
]);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const chart = lastChart();
|
|
||||||
expect(chart?.data.labels).toEqual(['2026-09-17T10:00:00Z', '2026-09-17T11:00:00Z']);
|
|
||||||
expect(chart?.data.datasets[0]['data']).toEqual([42, 60]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('détruit le graphique quand le composant est détruit', () => {
|
|
||||||
TestBed.configureTestingModule({ imports: [ReadingHistoryChart] });
|
|
||||||
const fixture = TestBed.createComponent(ReadingHistoryChart);
|
|
||||||
fixture.componentRef.setInput('readings', [READING]);
|
|
||||||
fixture.detectChanges();
|
|
||||||
|
|
||||||
const chart = lastChart();
|
|
||||||
fixture.destroy();
|
|
||||||
|
|
||||||
expect(chart?.destroy).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
ViewChild,
|
|
||||||
input,
|
|
||||||
effect,
|
|
||||||
AfterViewInit,
|
|
||||||
OnDestroy,
|
|
||||||
} from '@angular/core';
|
|
||||||
import { Chart, registerables } from 'chart.js';
|
|
||||||
import { Reading, ReadingDataQuality } from '../../models/reading.model';
|
|
||||||
|
|
||||||
Chart.register(...registerables);
|
|
||||||
|
|
||||||
const QUALITY_COLORS: Record<ReadingDataQuality, string> = {
|
|
||||||
good: '#3b82f6',
|
|
||||||
partial: '#f9a825',
|
|
||||||
degraded: '#ef6c00',
|
|
||||||
critical: '#c62828',
|
|
||||||
};
|
|
||||||
const UNKNOWN_QUALITY_COLOR = '#9ca3af';
|
|
||||||
|
|
||||||
interface ChartSeries {
|
|
||||||
labels: string[];
|
|
||||||
values: number[];
|
|
||||||
colors: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Piège : l'API renvoie les lectures du plus récent au plus ancien (ReadingRepository.list_history
|
|
||||||
// trie en timestamp desc) ; sans ce tri l'axe des abscisses se lirait à rebours.
|
|
||||||
function toSeries(readings: Reading[]): ChartSeries {
|
|
||||||
const ordered = [...readings].sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
|
||||||
return {
|
|
||||||
labels: ordered.map((r) => r.timestamp),
|
|
||||||
values: ordered.map((r) => r.consumption_kw ?? 0),
|
|
||||||
colors: ordered.map((r) =>
|
|
||||||
r.data_quality ? QUALITY_COLORS[r.data_quality] : UNKNOWN_QUALITY_COLOR,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-reading-history-chart',
|
|
||||||
standalone: true,
|
|
||||||
templateUrl: './reading-history-chart.html',
|
|
||||||
styleUrl: './reading-history-chart.scss',
|
|
||||||
})
|
|
||||||
export class ReadingHistoryChart implements AfterViewInit, OnDestroy {
|
|
||||||
readings = input.required<Reading[]>();
|
|
||||||
|
|
||||||
@ViewChild('canvas') private canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
||||||
private chart?: Chart<'line'>;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
effect(() => {
|
|
||||||
const series = toSeries(this.readings());
|
|
||||||
if (this.chart) {
|
|
||||||
this.chart.data.labels = series.labels;
|
|
||||||
this.chart.data.datasets[0].data = series.values;
|
|
||||||
this.chart.data.datasets[0].pointBackgroundColor = series.colors;
|
|
||||||
this.chart.update('none');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
const series = toSeries(this.readings());
|
|
||||||
this.chart = new Chart(this.canvasRef.nativeElement, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: series.labels,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: series.values,
|
|
||||||
borderColor: '#3b82f6',
|
|
||||||
pointBackgroundColor: series.colors,
|
|
||||||
tension: 0.25,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
scales: {
|
|
||||||
y: { beginAtZero: true, title: { display: true, text: 'Consommation (kW)' } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.chart?.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
export type ReadingSource = 'csv' | 'api_current' | 'api_history';
|
|
||||||
export type ReadingDataQuality = 'good' | 'partial' | 'degraded' | 'critical';
|
|
||||||
|
|
||||||
export interface Reading {
|
|
||||||
reading_id: number;
|
|
||||||
site_id: string;
|
|
||||||
timestamp: string;
|
|
||||||
source: ReadingSource;
|
|
||||||
consumption_kw: number | null;
|
|
||||||
consumption_kwh: number | null;
|
|
||||||
consumption_euros: string | null;
|
|
||||||
voltage_v: number | null;
|
|
||||||
current_a: number | null;
|
|
||||||
power_factor: number | null;
|
|
||||||
temperature_celsius: number | null;
|
|
||||||
humidity_percent: number | null;
|
|
||||||
solar_irradiance_wm2: number | null;
|
|
||||||
is_working_hours: boolean | null;
|
|
||||||
data_quality: ReadingDataQuality | null;
|
|
||||||
null_reasons: string[] | null;
|
|
||||||
imputed_values: Record<string, unknown> | null;
|
|
||||||
imputation_method: string | null;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type SensorStatus = 'ok' | 'failing';
|
||||||
|
export type OverallStatus = 'ok' | 'degraded' | 'critical';
|
||||||
|
|
||||||
|
export interface SensorDiagnostic {
|
||||||
|
status: SensorStatus;
|
||||||
|
since: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSensors {
|
||||||
|
consumption: SensorDiagnostic;
|
||||||
|
electrical: SensorDiagnostic;
|
||||||
|
temperature: SensorDiagnostic;
|
||||||
|
humidity: SensorDiagnostic;
|
||||||
|
network: SensorDiagnostic;
|
||||||
|
[key: string]: SensorDiagnostic;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteSensorStatus {
|
||||||
|
site_id: string;
|
||||||
|
site_name: string;
|
||||||
|
sensors: SiteSensors;
|
||||||
|
overall: OverallStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SensorStatusResponse {
|
||||||
|
timestamp: string;
|
||||||
|
sites: SiteSensorStatus[];
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { ReadingDataQuality } from './reading.model';
|
|
||||||
|
|
||||||
export interface SiteCurrent {
|
|
||||||
timestamp: string | null;
|
|
||||||
site_id: string;
|
|
||||||
site_type: string;
|
|
||||||
consumption_kw: number | null;
|
|
||||||
consumption_kwh: number | null;
|
|
||||||
voltage_v: number | null;
|
|
||||||
current_a: number | null;
|
|
||||||
power_factor: number | null;
|
|
||||||
temperature_celsius: number | null;
|
|
||||||
humidity_percent: number | null;
|
|
||||||
null_reasons: string[];
|
|
||||||
data_quality: ReadingDataQuality;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: false,
|
production: false,
|
||||||
apiUrl: '/api/v1',
|
apiUrl: '/api/v1',
|
||||||
useMockFixtures: false,
|
useMockFixtures: true, // a passer a false une fois le backend prêt
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -155,12 +155,10 @@ Deux fichiers d'environnement, deux usages : `.env` à la racine alimente `docke
|
|||||||
Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier
|
Les codes de la dernière colonne sont ceux que le schéma **déclare**, et le fichier
|
||||||
`openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent.
|
`openapi.json` versionné interdit qu'ils divergent de ce que les routes rendent.
|
||||||
|
|
||||||
**Sept routes du contrat sont publiques** : les deux sondes, `/auth/login`, `/auth/logout`,
|
**Quatre routes seulement sont publiques** : les deux sondes, `/auth/login` et `/auth/logout`.
|
||||||
`/auth/forgot-password` et les deux routes de réinitialisation, qui portent leur autorisation dans
|
|
||||||
le jeton à usage unique plutôt que dans un `Principal`.
|
|
||||||
`tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et
|
`tests/api/test_route_protection.py` interroge réellement chaque autre route sans identifiant et
|
||||||
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
|
échoue si l'une d'elles répond autre chose qu'un 401 ou un 403. Rendre une route publique impose
|
||||||
donc de modifier `ROUTES_PUBLIQUES` dans `tests/api/acces.py`.
|
donc de modifier la liste dans ce fichier de test.
|
||||||
|
|
||||||
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
|
`GET /sites` et `GET /sites/{site_id}` sont la première route métier, et le gabarit repris pour
|
||||||
`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches
|
`GET /alerts` puis pour les suivantes (`dataset`, `prediction`) : les quatre couches
|
||||||
@@ -275,16 +273,11 @@ Checklist pour toute nouvelle route sur le gabarit `sites`/`alerts`/`recommendat
|
|||||||
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
|
au niveau de l'`include_router()` du routeur, `REPONSE_VALIDATION` et les codes locaux
|
||||||
(404, 409, ...) directement sur l'endpoint qui les rend.
|
(404, 409, ...) directement sur l'endpoint qui les rend.
|
||||||
2. Décrire son tag dans `TAGS`.
|
2. Décrire son tag dans `TAGS`.
|
||||||
3. **La classer dans `tests/api/acces.py`** : `ROLE_MINIMUM` avec son rôle minimum si elle passe
|
3. Si elle passe par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), l'ajouter à
|
||||||
par `require_role` (`LecteurDep`/`OperateurDep`/`AdminDep`), `ROUTES_SANS_ROLE` si elle se
|
`ROUTES_A_ROLE` dans `tests/api/test_openapi.py`. Si elle passe par `require_trusted_origin`,
|
||||||
contente de `CurrentPrincipalDep`, `ROUTES_PUBLIQUES` si elle est ouverte. L'oubli n'est plus
|
l'ajouter à `ORIGINE_VERIFIEE`. **Ces deux listes sont maintenues à la main, pas dérivées** :
|
||||||
silencieux : `test_every_declared_route_is_classified` échoue sur une route non classée comme
|
une route oubliée n'y est pas détectée automatiquement.
|
||||||
sur une entrée qui ne correspond plus à aucune route. `ROUTES_A_ROLE` de `test_openapi.py` en
|
4. `make openapi`, puis `uv run pytest tests/api/test_openapi.py`.
|
||||||
est dérivée, et `test_matrice_acces.py` vérifie le niveau réellement monté.
|
|
||||||
4. Si elle passe par `require_trusted_origin`, l'ajouter à `ORIGINE_VERIFIEE` dans
|
|
||||||
`tests/api/test_openapi.py`. **Cette liste-là reste maintenue à la main.**
|
|
||||||
5. `make openapi`, puis `uv run pytest tests/api/test_openapi.py tests/api/test_route_protection.py
|
|
||||||
tests/api/test_matrice_acces.py`.
|
|
||||||
|
|
||||||
## Sécurité
|
## Sécurité
|
||||||
|
|
||||||
@@ -335,14 +328,9 @@ Le reste, par ordre de surface :
|
|||||||
|
|
||||||
Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md).
|
Conventions, gabarits et arborescence : [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md).
|
||||||
|
|
||||||
Quatre fichiers méritent d'être connus avant de toucher à l'authentification :
|
Trois fichiers méritent d'être connus avant de toucher à l'authentification :
|
||||||
|
|
||||||
- `tests/api/acces.py` : la classification des routes, `ROUTES_PUBLIQUES` et `ROLE_MINIMUM` en
|
|
||||||
tête. Ce n'est pas un test, c'est la référence que les deux suivants confrontent au
|
|
||||||
comportement observé.
|
|
||||||
- `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut.
|
- `tests/api/test_route_protection.py` : le garde-fou de l'autorisation, décrit plus haut.
|
||||||
- `tests/api/test_matrice_acces.py` : chaque route gardée croisée avec chacun des trois rôles,
|
|
||||||
dans les deux sens, puis rejouée sous `integration` avec de vrais jetons.
|
|
||||||
- `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les
|
- `tests/services/test_auth.py` : le faux hacheur y porte un compteur d'appels, ce qui permet les
|
||||||
deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro
|
deux assertions qui prouvent le design, à savoir un appel quand l'adresse est inconnue et zéro
|
||||||
appel quand la limite est atteinte.
|
appel quand la limite est atteinte.
|
||||||
|
|||||||
@@ -2,16 +2,8 @@ sonar.projectKey=ProjetPiscine_EnerVision
|
|||||||
sonar.organization=groupe3-ener-vision
|
sonar.organization=groupe3-ener-vision
|
||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
# Dossier contenant le code source
|
sonar.sources=apps/frontend/src,apps/backend/app
|
||||||
sonar.sources=apps/frontend/src,apps/backend
|
|
||||||
# Dossier contenant les tests
|
|
||||||
sonar.tests=apps/frontend/src,apps/backend/tests
|
|
||||||
sonar.test.inclusions=**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
|
||||||
|
|
||||||
# Liste des fichiers et dossiers à exclure de l'analyse
|
sonar.exclusions=**/node_modules/**,**/dist/**,**/*.spec.js,**/*.test.js,github,db,ml,docker-compose.yml,**/**/Dockerfile,**/**/proxy.conf.json,**/**/package.json,**/**/angular.json
|
||||||
sonar.exclusions=.pytest_cache,.venv,alembic,tests,**/*/node_modules/**,**/*/dist/**,**/*/build/**,**/*.spec.ts,**/*.test.ts,**/*test_*.py,**/*test.py
|
|
||||||
|
|
||||||
# Chemin vers le rapport de couverture de code
|
|
||||||
# Fichier généré par Pytest
|
|
||||||
sonar.python.coverage.reportPaths=apps/backend/coverage.xml
|
|
||||||
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
sonar.javascript.lcov.reportPaths=apps/frontend/coverage/frontend/lcov.info
|
||||||
|
|||||||
Reference in New Issue
Block a user