Compare commits

...
Author SHA1 Message Date
ineszang b8b8ca7163 Merge branch 'dev' into feat/pipeline-cd 2026-09-15 12:29:50 +02:00
ineszang 8efa034f9c feat(frontend): simplication de la conf nginx + ajout du stage pour le runner dans le dockerfile 2026-09-15 11:50:08 +02:00
Johan LEROYandGitHub 57c16c77f7 Merge pull request #66 from ineszang/18-provisionner-un-cluster-k8s-single-node-k3s-via-terraform
18 provisionner un cluster k8s single node k3s via terraform
2026-09-15 11:44:37 +02:00
Dorian PESCE 1103c6e1a6 Merge branch '18-provisionner-un-cluster-k8s-single-node-k3s-via-terraform' of https://github.com/ineszang/ProjetPiscine_EnerVision into 18-provisionner-un-cluster-k8s-single-node-k3s-via-terraform 2026-09-15 11:29:38 +02:00
Dorian PESCE b910e747ec fix: terraform & module 2026-09-15 11:29:34 +02:00
PhyriosandGitHub 4692f1d604 Merge branch 'dev' into 18-provisionner-un-cluster-k8s-single-node-k3s-via-terraform 2026-09-15 10:37:22 +02:00
Dorian PESCE 239efc8ee6 docs/update README 2026-09-15 10:32:22 +02:00
Johan LEROYandGitHub 0ca429ff3d Merge pull request #65 from ineszang/test/preparer-les-tests-unitaires-backend
Test/preparer les tests unitaires backend
2026-09-15 10:29:03 +02:00
Dorian PESCE a2727f9b5a chore/k8s-single-node-k3s via Terraform
Se connecte à la machine on-premise via SSH et installe k3s single-node puis instancie le module.

apply reste à faire une fois la machine prête.
2026-09-15 10:13:41 +02:00
Johan LEROY 6aeaca8ed1 docs: renvoie vers les conventions de tests
Ajoute test/ a la liste des prefixes de branches, deja utilise par la branche
d'outillage frontend, et remplace le corps a trous du gabarit de repository par
un exemple complet, que ruff format acceptait mal.
2026-09-15 10:02:17 +02:00
Johan LEROY 08ad3bbe34 docs(backend): consigne les conventions de tests unitaires
Pendant de apps/frontend/TESTING.md : ou ecrire un test, comment le nommer, quoi
tester selon la couche, les doubles par dependency_overrides, les marqueurs, et
quatre gabarits copiables.
2026-09-15 10:01:21 +02:00
Johan LEROY 50dfa72c9d test(backend): n'applique le seuil de couverture qu'aux suites completes
Dans [tool.coverage.report], fail_under vaut aussi pour une execution partielle :
make test-integration echouait a 71 % alors que son test passait, et un fichier
joue seul aurait echoue des que le code aurait grossi. Le seuil passe donc en
--cov-fail-under sur les cibles qui jouent toute la suite.
2026-09-15 10:01:09 +02:00
Johan LEROY d58647cad4 test(backend): fournit une session reelle aux tests d'integration
Les repositories a venir parlent du SQL : les eprouver sur un double ne prouve
rien. La fixture ouvre une vraie connexion, d'ou le marqueur integration.
2026-09-15 10:00:42 +02:00
Johan LEROY d14b3afc8e chore: ajoute une cible de rapports de tests
make test-cov produit la couverture HTML et XML et les resultats au format
JUnit, sans alourdir make test qui reste la boucle de developpement. Les trois
artefacts sont ignores, contrairement au junit.xml versione cote frontend.
2026-09-15 09:59:17 +02:00
Johan LEROY c95f4d3851 test(backend): mesure les branches et fixe un seuil de couverture
app/main.py sort du omit : la fixture app l'exerce a chaque test, et l'exclure
masquait ses seules conditions, les docs coupees hors developpement et le CORS
monte selon les origines declarees. Il ressort a 78 %, le lifespan n'etant pas
joue par ASGITransport.

Seuil pose a 85 % pour 89 % mesures.
2026-09-15 09:58:57 +02:00
ValentinDeFariaandGitHub 8f237f6d6f Merge pull request #62 from ineszang/test/préparer-les-tests-unitaires-frontend
Outillage tests unitaires frontend : couverture Vitest, scripts npm, …
2026-09-15 09:58:41 +02:00
Johan LEROY 3db4419bdf test(backend): calque l'arborescence des tests sur celle de app
Le README annonce deja tests/ comme miroir de app/, mais seul tests/api
existait. Les paquets core, db, services et repositories attendent le metier
a venir, pour que personne n'ait a choisir ou poser son premier test.
2026-09-15 09:58:30 +02:00
Johan LEROY 3ca1866e93 test(backend): factorise les doubles de session
Chaque test reecrivait sa classe de session et sa fonction d'override, soit
trois fois le meme decor pour un seul endpoint. FakeSession et la fixture
fake_session portent ce decor, make_settings fabrique une Settings dont les
valeurs priment sur l'environnement.
2026-09-15 09:57:46 +02:00
Johan LEROY 98ec01c847 test(backend): rend la configuration de test independante du poste
APP_ENV, APP_DEBUG, APP_LOG_LEVEL et APP_CORS_ORIGINS n'etaient poses nulle
part : le .env du developpeur les decidait, alors que les tests assertent en
dur l'environnement et que create_app coupe /openapi.json hors developpement.
Un poste portant APP_ENV=prod faisait tomber deux tests.

Fixe aussi asyncio_default_fixture_loop_scope, que pytest-asyncio 1.4 reclame.
2026-09-15 09:57:46 +02:00
ineszang 39b1daea51 feat(frontend): ajout du dockerfile 2026-09-15 09:39:23 +02:00
Johan LEROYandGitHub 552391c9bd Merge pull request #48 from ineszang/feat/db-timescaledb
feat(db): PostgreSQL 17 + TimescaleDB et connexion backend
2026-09-15 09:37:08 +02:00
valentin 34890b2b04 Outillage tests unitaires frontend : couverture Vitest, scripts npm, conventions TESTING.md. 2026-09-14 16:41:45 +02:00
ValentinDeFariaandGitHub 06a8ae42d2 Merge pull request #52 from ineszang/chore/init-frontend
Chore/init frontend
2026-09-14 15:53:52 +02:00
ineszangandGitHub 1fbacf2fa3 Merge pull request #56 from ineszang/chore/init-terraform
chore: Initialisation de Terraform
2026-09-14 15:38:17 +02:00
ineszang 0be2418e02 chore: Initialisation de Terraform 2026-09-14 14:46:08 +02:00
Johan LEROY 6bc2c3793f fix(db): monte db/init fichier par fichier et coupe la telemetrie
Monter le dossier ./db/init sur /docker-entrypoint-initdb.d remplacait le
dossier de l'image au lieu de s'y ajouter. Les trois scripts d'init livres
par timescaledb-ha disparaissaient sans aucun message : creation de
l'extension dans template1, reglage par timescaledb-tune, et installation de
timescaledb_toolkit. Verifie au demarrage : le dossier ne contenait que nos
deux fichiers, et timescaledb_toolkit etait absent des bases.

Monter chaque fichier separement retablit l'ordre attendu, verifie dans les
journaux : 000, 001, 010, puis 100 et 110.

TIMESCALEDB_TELEMETRY passe a off par defaut : l'image envoie sinon des
statistiques d'usage a Timescale, ce qui ne va pas pour un deploiement
on-premise.
2026-09-14 14:28:49 +02:00
Johan LEROY f4d05a8ca9 docs: ADR du choix PostgreSQL TimescaleDB
Acte le choix de l'extension plutot qu'un second SGBD, celui de l'image -ha
et celui de PG17. Fixe surtout la frontiere db/init contre db/migrations
contre apps/backend/alembic, qui n'est deductible d'aucun fichier.
2026-09-14 14:19:25 +02:00
Johan LEROY 20e7374d90 feat(backend): verifie l extension TimescaleDB sur la sonde de disponibilite
/api/v1/health/ready interrogeait la base par un SELECT 1, qui ne distingue
pas un PostgreSQL nu d'un PostgreSQL avec TimescaleDB. La sonde lit desormais
pg_extension et repond 503 si l'extension manque, cas qui survient quand
db/init n'a pas ete joue.

- Premiere revision Alembic : aucune table, une garde qui refuse de
  s'appliquer sans l'extension.
- Tests du chemin nominal et de l'extension absente, plus un test marque
  `integration` contre la vraie base. pytest ecarte ce marqueur par defaut
  pour que make check reste jouable sans Docker.
- conftest recycle l'engine entre les tests : get_engine est lru_cache alors
  que pytest-asyncio ouvre une boucle par test, et les connexions asyncpg
  sont liees a leur boucle.
2026-09-14 14:19:25 +02:00
Johan LEROY 351e928309 feat(db): bootstrap PostgreSQL et extension TimescaleDB
Service `db` du docker-compose racine sur timescale/timescaledb-ha:pg17,
volume nomme et cibles Makefile db-up / db-down / db-reset / db-logs / db-psql.

- db/init/100-extensions.sql declare l'extension attendue, db/init/110 cree
  la base enervision_test utilisee par la suite de tests du backend.
- Numerotation a partir de 100 : l'image depose ses propres scripts 000, 001
  et 010, et un prefixe a deux chiffres se trie avant 010 en locale C.
- Volume monte sur /home/postgres/pgdata/data, PGDATA de cette image. Monte
  au chemin habituel de l'image postgres, il ne retiendrait rien sans erreur.
- Port publie 5433 par defaut, 5432 etant souvent deja pris sur un poste.
2026-09-14 14:19:25 +02:00
Johan LEROY 91f4f007d3 fix(backend): corrige la syntaxe du bloc except de la sonde de disponibilite
`except SQLAlchemyError, OSError:` est de la syntaxe Python 2. Le module
health.py ne s'importait pas, ce qui cassait make dev, make test,
make typecheck et alembic.
2026-09-14 14:13:45 +02:00
PhyriosandGitHub 4de5fb0935 Merge pull request #23 from ineszang/chore/init-monorepo
Chore/init monorepo
2026-09-14 13:42:24 +02:00
41 changed files with 1177 additions and 36 deletions
+17
View File
@@ -0,0 +1,17 @@
# Variables lues par docker-compose.yml a la racine.
# Le backend lance hors conteneur (`make dev`) lit apps/backend/.env, pas ce fichier.
POSTGRES_USER=enervision
POSTGRES_PASSWORD=change_me
POSTGRES_DB=enervision
# 5432 est souvent deja pris par une autre base du poste.
POSTGRES_PORT=5433
# `basic` renvoie des statistiques d'usage a Timescale.
TIMESCALEDB_TELEMETRY=off
APP_ENV=local
APP_DEBUG=true
APP_LOG_LEVEL=INFO
APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200
BACKEND_PORT=8000
+5 -1
View File
@@ -9,6 +9,7 @@ venv/
.coverage .coverage
coverage.xml coverage.xml
htmlcov/ htmlcov/
test-results/
dist/ dist/
build/ build/
*.egg-info/ *.egg-info/
@@ -23,7 +24,7 @@ yarn-error.log*
# Terraform # Terraform
.terraform/ .terraform/
.terraform.lock.hcl # .terraform.lock.hcl est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI
*.tfstate *.tfstate
*.tfstate.* *.tfstate.*
*.tfplan *.tfplan
@@ -32,6 +33,9 @@ override.tf
override.tf.json override.tf.json
*_override.tf *_override.tf
*_override.tf.json *_override.tf.json
*.tfvars
!*.tfvars.example
kubeconfig
# Airflow # Airflow
etl/airflow/logs/ etl/airflow/logs/
+29 -3
View File
@@ -1,7 +1,8 @@
BACKEND := apps/backend BACKEND := apps/backend
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
.PHONY: help install dev lint format typecheck test check docker-build .PHONY: help install dev lint format typecheck test test-cov test-integration check \
docker-build db-up db-down db-reset db-logs db-psql migrate
help: ## Liste les cibles disponibles help: ## Liste les cibles disponibles
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
@@ -21,10 +22,35 @@ format: ## Formate et corrige le backend
typecheck: ## Verifie le typage du backend typecheck: ## Verifie le typage du backend
cd $(BACKEND) && uv run mypy app cd $(BACKEND) && uv run mypy app
test: ## Execute les tests backend test: ## Execute les tests backend ne demandant pas de base
cd $(BACKEND) && uv run pytest cd $(BACKEND) && uv run pytest --cov-fail-under=85
test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit
cd $(BACKEND) && uv run pytest --cov-fail-under=85 --cov-report=html \
--cov-report=xml --junitxml=test-results/junit.xml
test-integration: ## Execute les tests exigeant une base joignable
cd $(BACKEND) && uv run pytest -m integration
check: lint typecheck test ## Chaine de verification complete check: lint typecheck test ## Chaine de verification complete
docker-build: ## Construit l'image du backend docker-build: ## Construit l'image du backend
docker build -t enervision-backend:local $(BACKEND) docker build -t enervision-backend:local $(BACKEND)
db-up: ## Demarre la base PostgreSQL TimescaleDB
docker compose up -d db
db-down: ## Arrete la base en conservant ses donnees
docker compose stop db
db-reset: ## Detruit la base et rejoue db/init
docker compose down -v && docker compose up -d db
db-logs: ## Suit les journaux de la base
docker compose logs -f db
db-psql: ## Ouvre une session psql sur la base applicative
docker compose exec db psql -U $${POSTGRES_USER:-enervision} -d $${POSTGRES_DB:-enervision}
migrate: ## Applique les migrations Alembic
cd $(BACKEND) && uv run alembic upgrade head
+25 -5
View File
@@ -9,14 +9,14 @@ series temporelles energetiques, deployee sur une machine on-premise.
|------------|-------------------------------------|---------------------|---------------| |------------|-------------------------------------|---------------------|---------------|
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise | | Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
| Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser | | Frontend | Angular, Node 24 LTS | `apps/frontend` | A initialiser |
| Base | PostgreSQL + TimescaleDB | `db` | A initialiser | | Base | PostgreSQL 17 + TimescaleDB | `db` | Initialise |
| ETL | Apache Airflow | `etl/airflow` | A initialiser | | ETL | Apache Airflow | `etl/airflow` | A initialiser |
| Infra | Terraform | `infra/terraform` | A initialiser | | Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
| CI/CD | GitHub Actions | `.github/workflows` | A initialiser | | CI/CD | GitHub Actions | `.github/workflows` | A initialiser |
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
Seul le backend est initialise a ce stade. Les autres dossiers portent l'arborescence et Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Les autres dossiers
un README de cadrage, leur contenu fait l'objet d'un ticket dedie. portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
## Arborescence ## Arborescence
@@ -50,15 +50,35 @@ un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul. Prerequis : uv, Docker. Le poste doit disposer de Python 3.14, que `uv` installe seul.
```bash ```bash
cp .env.example .env # variables de docker-compose
cp apps/backend/.env.example apps/backend/.env # variables du backend hors conteneur
make db-up # PostgreSQL + TimescaleDB, publie sur le port 5433
make install # dependances du backend make install # dependances du backend
make migrate # applique les migrations Alembic
make dev # API sur http://localhost:8000, docs sur /docs make dev # API sur http://localhost:8000, docs sur /docs
make check # lint + typage + tests make check # lint + typage + tests
``` ```
`make help` liste les cibles disponibles. `make help` liste les cibles disponibles.
Deux fichiers d'environnement, deux usages : `.env` a la racine alimente `docker-compose.yml`,
`apps/backend/.env` alimente le backend lance sur le poste. Le port 5433 est publie plutot que
5432, souvent deja pris par une autre base.
La boucle de developpement est `make db-up` puis `make dev` : seule la base tourne en
conteneur. Le service `backend` du `docker-compose.yml` sert la stack complete et la recette,
et n'embarque pas le source, donc toute modification y demande un
`docker compose up -d --build backend`.
Verifier que la base repond et que l'extension est chargee :
```bash
curl -s localhost:8000/api/v1/health/ready
```
## Conventions ## Conventions
- Branches : `feat/`, `fix/`, `chore/`, `docs/` suivi d'un libelle court. - Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court.
- Commits : Conventional Commits, portee = dossier de premier niveau concerne. - Commits : Conventional Commits, portee = dossier de premier niveau concerne.
- Toute decision structurante donne lieu a un ADR dans `docs/adr`. - Toute decision structurante donne lieu a un ADR dans `docs/adr`.
+1 -1
View File
@@ -3,4 +3,4 @@ APP_DEBUG=true
APP_LOG_LEVEL=INFO APP_LOG_LEVEL=INFO
APP_SECRET_KEY=change_me APP_SECRET_KEY=change_me
APP_CORS_ORIGINS=http://localhost:4200 APP_CORS_ORIGINS=http://localhost:4200
DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5432/enervision DATABASE_URL=postgresql+asyncpg://enervision:change_me@localhost:5433/enervision
+16 -1
View File
@@ -22,6 +22,9 @@ uv sync --all-groups
`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse `APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse
de demarrer sans elles. de demarrer sans elles.
`DATABASE_URL` pointe sur `localhost:5433`, le port publie par le service `db` du
`docker-compose.yml` racine. Demarrer la base depuis la racine avec `make db-up`.
## Commandes ## Commandes
Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`, Depuis la racine du monorepo, via le `Makefile` : `make install`, `make dev`, `make lint`,
@@ -35,8 +38,16 @@ uv run ruff check . # lint
uv run ruff format . # format uv run ruff format . # format
uv run mypy app # typage strict uv run mypy app # typage strict
uv run pytest # tests + couverture uv run pytest # tests + couverture
uv run pytest -m integration # tests exigeant une base joignable
``` ```
Les conventions de tests, les gabarits et le detail des marqueurs sont dans
[`TESTING.md`](TESTING.md).
`pytest` ecarte par defaut les tests marques `integration`, pour que `make check` reste
jouable sans Docker. Ces tests visent la base `enervision_test`, creee par
`db/init/110-test-database.sql` au premier demarrage du conteneur.
L'application est exposee par une factory (`create_app`) et non par un objet module : L'application est exposee par une factory (`create_app`) et non par un objet module :
aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations
independants de l'environnement. independants de l'environnement.
@@ -73,7 +84,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
| Route | Role | | Route | Role |
|------------------------|-------------------------------------------------| |------------------------|-------------------------------------------------|
| `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe | | `/api/v1/health/live` | Sonde de vivacite, aucune dependance externe |
| `/api/v1/health/ready` | Sonde de disponibilite, verifie la base | | `/api/v1/health/ready` | Sonde de disponibilite, verifie la base et TimescaleDB |
| `/metrics` | Metriques au format Prometheus | | `/metrics` | Metriques au format Prometheus |
| `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` | | `/docs`, `/openapi.json` | Documentation, desactivee quand `APP_ENV=prod` |
@@ -86,6 +97,10 @@ uv run alembic upgrade head
L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`. L'URL de connexion vient de `DATABASE_URL`, pas de `alembic.ini`.
La premiere revision ne cree aucune table : elle refuse de s'appliquer si l'extension
TimescaleDB manque, ce qui arrive quand `db/init` n'a pas ete joue. Le DDL propre a
TimescaleDB qui ne depend pas du schema applicatif vit dans `db/`, pas ici.
## Image Docker ## Image Docker
Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un
+143
View File
@@ -0,0 +1,143 @@
# Conventions de tests unitaires : Backend
## Outil
pytest, avec pytest-asyncio en mode `auto` : un `async def test_*` est collecte sans
decorateur. Les appels HTTP passent par httpx sur `ASGITransport`, qui parle a
l'application en memoire, sans serveur ni port ouvert.
## Ou ecrire les tests
`tests/` est le miroir de `app/` : un test de `app/services/consumption.py` va dans
`tests/services/test_consumption.py`. Les paquets `core`, `db`, `services` et
`repositories` existent deja, vides, pour cette raison.
## Nommage
- Fonctions en anglais : `test_<sujet>_<comportement>_when_<condition>`.
- `ids=` de `parametrize` en francais : `ids=["erreur_sqlalchemy", "erreur_reseau"]`.
- Pas de docstring : le nom porte l'intention.
## Structure attendue (Arrange / Act / Assert)
Une ligne vide separe les trois temps, sans commentaire pour les annoncer.
```python
async def test_readiness_returns_503_when_the_extension_is_missing(
fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=None)
response = await client.get("/api/v1/health/ready")
assert response.status_code == 503
assert response.json()["detail"] == "Extension TimescaleDB absente"
```
## Ce qui doit etre teste en priorite
Le sens de dependance du backend est `endpoints -> services -> repositories -> models`.
| Couche | Ce qu'on teste |
|---|---|
| `services/` | La logique metier, cas nominal et cas d'erreur. C'est la priorite. |
| `repositories/` | Chaque branche de decision, sous le marqueur `integration`. |
| `endpoints/` | Le code de statut et la forme de la reponse, pas la logique metier. |
| `schemas/` | Rien, sauf si le schema porte une validation ecrite a la main. |
## Doubles
On remplace une dependance FastAPI par `app.dependency_overrides`, jamais par
`unittest.mock`. `tests/factories.py` fournit le necessaire.
- `fake_session(result=...)` : la session repond `result`.
- `fake_session(failure=...)` : la session leve l'exception.
- `make_settings(**overrides)` : fabrique une `Settings`, dont les valeurs priment sur
l'environnement et sur `.env`. C'est le moyen de tester `create_app` en `prod`.
## Gabarit : un endpoint
```python
from collections.abc import Callable
from httpx import AsyncClient
async def test_endpoint_returns_the_expected_payload(
fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=42)
response = await client.get("/api/v1/...")
assert response.status_code == 200
assert response.json() == {"valeur": 42}
```
## Gabarit : un service avec repository factice
Un service ne connait que son repository : on lui en passe un faux, sans base ni session.
```python
from app.services.consumption import ConsumptionService
class FakeRepository:
async def total_for(self, site_id: int) -> float:
return 12.5
async def test_service_converts_the_total_to_kilowatt_hours() -> None:
service = ConsumptionService(FakeRepository())
total = await service.total_kwh(site_id=1)
assert total == 12.5
```
## Gabarit : un repository sur la vraie base
Un repository parle du SQL : le tester sur un double ne prouve rien. Il porte donc le
marqueur `integration`, ecarte par defaut.
```python
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.site import Site
from app.repositories.site import SiteRepository
@pytest.mark.integration
async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> None:
repository = SiteRepository(session)
await repository.add(Site(name="Toulouse"))
assert await repository.by_name("Toulouse") is not None
```
## Marqueurs
`integration` designe tout test exigeant une base joignable. `pytest` les ecarte par
defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre
declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus.
## Couverture
Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique
qu'aux cibles qui jouent toute la suite, `make test` et `make test-cov` : un fichier
joue seul affiche sa couverture sans jamais echouer dessus. Le detail se lit dans
`htmlcov/index.html` apres `make test-cov`.
## Lancer les tests
```bash
make test # suite unitaire, sans base
make test-cov # idem, plus les rapports HTML, XML et JUnit
make db-up && make test-integration # tests exigeant une base, demande Docker
make check # lint + typage + suite unitaire
uv run pytest tests/api/test_health.py # un seul fichier
uv run pytest -k readiness # par motif de nom
```
@@ -0,0 +1,37 @@
"""socle garde extension timescaledb
Revision ID: 5353c0e4f094
Revises:
Create Date: 2026-09-14 14:17:17.556764
Premiere revision du schema applicatif. Elle ne cree aucune table : elle etablit
alembic_version et refuse de s'appliquer sur une base ou l'extension TimescaleDB
manque, cas qui se produit quand db/init n'a pas ete joue.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "5353c0e4f094"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
GARDE_EXTENSION = """
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'timescaledb') THEN
RAISE EXCEPTION 'extension timescaledb absente, voir db/init et db/README.md';
END IF;
END
$$;
"""
def upgrade() -> None:
op.execute(GARDE_EXTENSION)
def downgrade() -> None:
pass
+12 -2
View File
@@ -9,6 +9,8 @@ from app.schemas.health import LivenessStatus, ReadinessStatus
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
@router.get("/live", summary="Sonde de vivacite") @router.get("/live", summary="Sonde de vivacite")
async def liveness(settings: SettingsDep) -> LivenessStatus: async def liveness(settings: SettingsDep) -> LivenessStatus:
@@ -23,11 +25,19 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
@router.get("/ready", summary="Sonde de disponibilite") @router.get("/ready", summary="Sonde de disponibilite")
async def readiness(session: SessionDep) -> ReadinessStatus: async def readiness(session: SessionDep) -> ReadinessStatus:
try: try:
await session.execute(text("SELECT 1")) version: str | None = await session.scalar(TIMESCALEDB_VERSION)
except SQLAlchemyError, OSError: except SQLAlchemyError, OSError:
logger.exception("Base de donnees injoignable") logger.exception("Base de donnees injoignable")
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Base de donnees injoignable", detail="Base de donnees injoignable",
) from None ) from None
return ReadinessStatus(status="ready", database="reachable")
if version is None:
logger.error("Extension TimescaleDB absente de la base")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Extension TimescaleDB absente",
)
return ReadinessStatus(status="ready", database="reachable", timescaledb=version)
+1
View File
@@ -13,3 +13,4 @@ class LivenessStatus(BaseModel):
class ReadinessStatus(BaseModel): class ReadinessStatus(BaseModel):
status: Literal["ready"] status: Literal["ready"]
database: Literal["reachable"] database: Literal["reachable"]
timescaledb: str
+8 -2
View File
@@ -79,8 +79,14 @@ disallow_untyped_defs = false
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
addopts = "-q --strict-markers --cov=app --cov-report=term-missing" asyncio_default_fixture_loop_scope = "function"
addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing"
markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"]
[tool.coverage.run] [tool.coverage.run]
source = ["app"] source = ["app"]
omit = ["app/main.py", "alembic/*"] branch = true
omit = ["alembic/*"]
[tool.coverage.report]
show_missing = true
+40 -13
View File
@@ -1,12 +1,9 @@
from collections.abc import AsyncIterator from collections.abc import Callable
import pytest import pytest
from fastapi import FastAPI
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from app.db.session import get_session
async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None:
response = await client.get("/api/v1/health/live") response = await client.get("/api/v1/health/live")
@@ -20,6 +17,32 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None:
} }
async def test_readiness_reports_the_timescaledb_version(
fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result="2.22.1")
response = await client.get("/api/v1/health/ready")
assert response.status_code == 200
assert response.json() == {
"status": "ready",
"database": "reachable",
"timescaledb": "2.22.1",
}
async def test_readiness_returns_503_when_the_extension_is_missing(
fake_session: Callable[..., None], client: AsyncClient
) -> None:
fake_session(result=None)
response = await client.get("/api/v1/health/ready")
assert response.status_code == 503
assert response.json()["detail"] == "Extension TimescaleDB absente"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"failure", "failure",
[ [
@@ -29,16 +52,9 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None:
ids=["erreur_sqlalchemy", "erreur_reseau_asyncpg"], ids=["erreur_sqlalchemy", "erreur_reseau_asyncpg"],
) )
async def test_readiness_returns_503_when_database_is_unreachable( async def test_readiness_returns_503_when_database_is_unreachable(
app: FastAPI, client: AsyncClient, failure: Exception fake_session: Callable[..., None], client: AsyncClient, failure: Exception
) -> None: ) -> None:
class UnreachableSession: fake_session(failure=failure)
async def execute(self, *_: object, **__: object) -> None:
raise failure
async def override() -> AsyncIterator[UnreachableSession]:
yield UnreachableSession()
app.dependency_overrides[get_session] = override
response = await client.get("/api/v1/health/ready") response = await client.get("/api/v1/health/ready")
@@ -49,3 +65,14 @@ async def test_readiness_returns_503_when_database_is_unreachable(
@pytest.mark.parametrize("path", ["/openapi.json", "/metrics"]) @pytest.mark.parametrize("path", ["/openapi.json", "/metrics"])
async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None: async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None:
assert (await client.get(path)).status_code == 200 assert (await client.get(path)).status_code == 200
@pytest.mark.integration
async def test_readiness_reaches_the_real_database(client: AsyncClient) -> None:
response = await client.get("/api/v1/health/ready")
assert response.status_code == 200, response.text
body = response.json()
assert body["status"] == "ready"
assert body["database"] == "reachable"
assert body["timescaledb"]
+45 -3
View File
@@ -1,25 +1,49 @@
import os import os
from collections.abc import AsyncIterator, Iterator from collections.abc import AsyncIterator, Callable, Iterator
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings from app.core.config import get_settings
from app.db.session import get_engine, get_session, get_session_factory
from app.main import create_app from app.main import create_app
from tests.factories import FakeSession
# Piege : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne
# pose pas ici, c'est le .env du poste qui les decide, et les assertions avec.
@pytest.fixture(autouse=True, scope="session") @pytest.fixture(autouse=True, scope="session")
def environment() -> Iterator[None]: def environment() -> Iterator[None]:
os.environ.setdefault("APP_SECRET_KEY", "secret-de-test") os.environ.update(
{
"APP_ENV": "local",
"APP_DEBUG": "false",
"APP_LOG_LEVEL": "WARNING",
"APP_CORS_ORIGINS": "",
"APP_SECRET_KEY": "secret-de-test",
}
)
os.environ.setdefault( os.environ.setdefault(
"DATABASE_URL", "postgresql+asyncpg://enervision:enervision@localhost:5432/enervision_test" "DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test"
) )
get_settings.cache_clear() get_settings.cache_clear()
yield yield
get_settings.cache_clear() get_settings.cache_clear()
# Piege : get_engine est lru_cache et pytest-asyncio ouvre une boucle par test. Sans ce
# recyclage, le 2e test touchant vraiment la base heriterait d une boucle morte.
@pytest.fixture(autouse=True)
async def engine_per_test() -> AsyncIterator[None]:
yield
if get_engine.cache_info().currsize:
await get_engine().dispose()
get_engine.cache_clear()
get_session_factory.cache_clear()
@pytest.fixture @pytest.fixture
def app() -> FastAPI: def app() -> FastAPI:
return create_app() return create_app()
@@ -30,3 +54,21 @@ async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as async_client: async with AsyncClient(transport=transport, base_url="http://test") as async_client:
yield async_client yield async_client
@pytest.fixture
def fake_session(app: FastAPI) -> Callable[..., None]:
def install(result: object = None, failure: Exception | None = None) -> None:
async def override() -> AsyncIterator[FakeSession]:
yield FakeSession(result=result, failure=failure)
app.dependency_overrides[get_session] = override
return install
# Contrainte : ouvre une vraie connexion, donc reservee aux tests `integration`.
@pytest.fixture
async def session() -> AsyncIterator[AsyncSession]:
async with get_session_factory()() as async_session:
yield async_session
+37
View File
@@ -0,0 +1,37 @@
from typing import Any
from app.core.config import Settings
SETTINGS_DE_TEST: dict[str, Any] = {
"env": "local",
"debug": False,
"log_level": "WARNING",
"cors_origins": "",
"secret_key": "secret-de-test",
"database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test",
}
class FakeSession:
"""Session factice : renvoie `result`, ou leve `failure` si elle est fournie."""
def __init__(self, result: object = None, failure: Exception | None = None) -> None:
self._result = result
self._failure = failure
async def scalar(self, *_: object, **__: object) -> object:
return self._repondre()
async def execute(self, *_: object, **__: object) -> object:
return self._repondre()
def _repondre(self) -> object:
if self._failure is not None:
raise self._failure
return self._result
# Piege : les arguments nommes priment sur l'environnement et sur .env, contrairement
# aux variables posees par la fixture `environment`, qui restent surchargeables.
def make_settings(**overrides: Any) -> Settings:
return Settings(**{**SETTINGS_DE_TEST, **overrides})
+47
View File
@@ -0,0 +1,47 @@
# ==================
# Étape 1 : Build
# ==================
# Image pour frontend
FROM dhi.io/node:24-alpine3.22-dev AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
# Installation des dépendances du projet avec npm
RUN --mount=type=cache,target=/root/.npm npm ci
# Copie du code source vers le conteneur
COPY . .
# Build
RUN npm run build
# ==================
# Étape 2 : Runner
# ==================
FROM dhi.io/nginx:1.28.0-alpine3.21-dev AS runner
# Copie de la configuration de nginx
COPY --chown=nginx:nginx nginx.conf /etc/nginx/nginx.conf
# Copy the static build output from the build stage to Nginx's default HTML serving directory
COPY --chown=nginx:nginx --from=builder /app/dist/*/browser /usr/share/nginx/html
# Create necessary directories with proper permissions for nginx
RUN mkdir -p /var/log/nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx /var/cache/nginx /usr/share/nginx/html
# Use a non-root user for security best practices
USER nginx
# Frontend : port 3000
# Backend : port 8000
EXPOSE 3000
# Start Nginx directly with custom config
ENTRYPOINT ["nginx", "-c", "/etc/nginx/nginx.conf"]
CMD ["-g", "daemon off;"]
+81
View File
@@ -0,0 +1,81 @@
# Conventions de tests unitaires — Frontend
## Outil
Vitest (intégré nativement à Angular CLI, pas d'installation à faire).
## Où écrire les tests
Un fichier `*.spec.ts` à côté de chaque fichier testé (convention Angular CLI
par défaut, respectée automatiquement par `ng generate`).
## Structure attendue (Arrange / Act / Assert)
```typescript
it('devrait faire X quand Y', () => {
// Arrange : préparer les données et les mocks
const input = { valeur: 42 };
// Act : exécuter le code testé
const result = service.doSomething(input);
// Assert : vérifier le résultat
expect(result).toBe(true);
});
```
## Ce qui doit être testé en priorité
- Services (`core/services/`) : logique métier, gestion des erreurs
- Guards et interceptors (`core/guards/`, `core/interceptors/`) : chaque branche de décision
- Composants avec logique (formulaires, conditions d'affichage) — pas nécessaire pour
un composant 100% template, sans logique
## Gabarit — tester un service avec appel HTTP
```typescript
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
import { MonService } from './mon.service';
describe('MonService', () => {
let service: MonService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [MonService, provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(MonService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => httpMock.verify());
it('devrait récupérer les données', () => {
service.getData().subscribe();
const req = httpMock.expectOne('/api/v1/...');
expect(req.request.method).toBe('GET');
req.flush({ /* réponse simulée */ });
});
});
```
## Gabarit — tester un composant standalone
```typescript
import { TestBed } from '@angular/core/testing';
import { MonComposant } from './mon-composant';
describe('MonComposant', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MonComposant],
}).compileComponents();
});
it('devrait se créer', () => {
const fixture = TestBed.createComponent(MonComposant);
expect(fixture.componentInstance).toBeTruthy();
});
});
```
## Lancer les tests
- Développement (mode watch) : `npm test`
- Rapport de couverture (CI) : `npm run test:ci -- --coverage`, puis ouvrir `coverage/index.html`
+18 -1
View File
@@ -77,7 +77,24 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"test": { "test": {
"builder": "@angular/build:unit-test" "builder": "@angular/build:unit-test",
"options": {
"coverage": true,
"coverageReporters": [
"text-summary",
"lcov",
"html"
],
"reporters": [
"default",
[
"junit",
{
"outputFile": "test-results/junit.xml"
}
]
]
}
} }
} }
} }
+32
View File
@@ -0,0 +1,32 @@
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~ /\. {
deny all;
}
}
}
+201
View File
@@ -21,6 +21,7 @@
"@angular/build": "^22.1.8", "@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8", "@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0", "@angular/compiler-cli": "^22.1.0",
"@vitest/coverage-v8": "^4.1.11",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
@@ -733,6 +734,16 @@
"node": "^22.18.0 || >=24.11.0" "node": "^22.18.0 || >=24.11.0"
} }
}, },
"node_modules/@bcoe/v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@bramus/specificity": { "node_modules/@bramus/specificity": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -3692,6 +3703,37 @@
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/@vitest/coverage-v8": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.11",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-reports": "^3.2.0",
"magicast": "^0.5.2",
"obug": "^2.1.1",
"std-env": "^4.0.0-rc.1",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "4.1.11", "version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
@@ -3943,6 +3985,18 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/ast-v8-to-istanbul": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.6.tgz",
"integrity": "sha512-fvpl29helSO2w/z7utIbrkNXILdrLwDwAMH2I/zPKlGf5244+gf+B4cyS1sANcrPY2h+hWCGSgC8N61s/+AF9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.31",
"estree-walker": "^3.0.3",
"js-tokens": "^10.0.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.23", "version": "2.11.23",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
@@ -5077,6 +5131,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -5139,6 +5203,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0" "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
} }
}, },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
@@ -5382,6 +5453,45 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=8"
}
},
"node_modules/istanbul-lib-report": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"istanbul-lib-coverage": "^3.0.0",
"make-dir": "^4.0.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/istanbul-reports": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"html-escaper": "^2.0.0",
"istanbul-lib-report": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/jose": { "node_modules/jose": {
"version": "6.2.12", "version": "6.2.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
@@ -5886,6 +5996,84 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/magicast": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz",
"integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"source-map-js": "^1.2.1"
}
},
"node_modules/magicast/node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/magicast/node_modules/@babel/helper-validator-identifier": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/magicast/node_modules/@babel/parser": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/magicast/node_modules/@babel/types": {
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"semver": "^7.5.3"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -7088,6 +7276,19 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1" "url": "https://github.com/chalk/strip-ansi?sponsor=1"
} }
}, },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/symbol-tree": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+3 -1
View File
@@ -6,7 +6,8 @@
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test",
"test:ci": "ng test --watch=false"
}, },
"private": true, "private": true,
"packageManager": "npm@11.19.0", "packageManager": "npm@11.19.0",
@@ -24,6 +25,7 @@
"@angular/build": "^22.1.8", "@angular/build": "^22.1.8",
"@angular/cli": "^22.1.8", "@angular/cli": "^22.1.8",
"@angular/compiler-cli": "^22.1.0", "@angular/compiler-cli": "^22.1.0",
"@vitest/coverage-v8": "^4.1.11",
"jsdom": "^28.0.0", "jsdom": "^28.0.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "~6.0.2", "typescript": "~6.0.2",
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<testsuites name="vitest tests" tests="2" failures="0" errors="0" time="0.0699261">
<testsuite name="src/app/app.spec.ts" timestamp="2026-09-14T14:27:28.895Z" hostname="76SE37-GL5HHZ3" tests="2" failures="0" errors="0" skipped="0" time="0.0699261">
<testcase classname="src/app/app.spec.ts" name="App &gt; should create the app" time="0.0527847">
</testcase>
<testcase classname="src/app/app.spec.ts" name="App &gt; should render title" time="0.015831">
</testcase>
</testsuite>
</testsuites>
+30 -1
View File
@@ -1,6 +1,7 @@
# Base de donnees # Base de donnees
PostgreSQL avec l'extension TimescaleDB. Non initialise, voir le ticket dedie. PostgreSQL 17 avec l'extension TimescaleDB, servie en local par le service `db` du
`docker-compose.yml` racine (image `timescale/timescaledb-ha:pg17`).
- `init` : scripts de bootstrap joues au premier demarrage du conteneur. - `init` : scripts de bootstrap joues au premier demarrage du conteneur.
- `migrations` : migrations SQL versionnees. - `migrations` : migrations SQL versionnees.
@@ -8,3 +9,31 @@ PostgreSQL avec l'extension TimescaleDB. Non initialise, voir le ticket dedie.
Les migrations du schema applicatif expose par l'API vivent dans Les migrations du schema applicatif expose par l'API vivent dans
`apps/backend/alembic`, pas ici. `apps/backend/alembic`, pas ici.
## `init` ne rejoue jamais
Ces scripts sont montes sur `/docker-entrypoint-initdb.d`, dont PostgreSQL ne joue le
contenu qu'a la toute premiere initialisation, quand `PGDATA` est vide. Modifier ou
ajouter un script ensuite reste sans effet sur une base existante : il faut detruire
le volume, ce que fait `make db-reset`.
L'image apporte ses propres scripts dans ce dossier, et ils comptent :
| Script | Origine | Role |
|---|---|---|
| `000_install_timescaledb.sh` | image | Cree l'extension dans `postgres`, `template1` et la base applicative, et fixe `timescaledb.telemetry_level`. |
| `001_timescaledb_tune.sh` | image | Lance `timescaledb-tune` sur la memoire et les CPU vus par le conteneur. |
| `010_install_timescaledb_toolkit.sh` | image | Ajoute `timescaledb_toolkit`. |
| `100-extensions.sql` | ce depot | Declare explicitement les extensions attendues. |
| `110-test-database.sql` | ce depot | Cree `enervision_test`, attendue par la suite de tests du backend. |
D'ou deux contraintes dans `docker-compose.yml`. Nos fichiers sont **montes un par un**,
et non par leur dossier : un montage de `./db/init` sur `/docker-entrypoint-initdb.d`
remplacerait le dossier de l'image au lieu de s'y ajouter, et ferait disparaitre les trois
scripts ci-dessus sans le moindre message. Ajouter un fichier ici impose donc d'ajouter
une ligne la-bas. Et leur numerotation commence a `100` pour passer apres `010`, y compris
en locale C ou un prefixe a deux chiffres se trierait avant.
Comme un bootstrap peut toujours avoir ete saute, deux gardes le rattrapent :
`/api/v1/health/ready` repond 503 si l'extension n'est pas chargee, et la premiere
revision Alembic refuse de s'appliquer.
+4
View File
@@ -0,0 +1,4 @@
-- Piege : ce script ne rejoue qu'a la premiere initialisation, quand PGDATA est vide.
-- Le modifier ensuite reste sans effet tant que le volume n'est pas detruit.
CREATE EXTENSION IF NOT EXISTS timescaledb;
+8
View File
@@ -0,0 +1,8 @@
-- Contrainte : le nom de cette base est code en dur dans apps/backend/tests/conftest.py.
-- Elle sert la suite de tests de la stack locale, pas un deploiement.
CREATE DATABASE enervision_test;
\connect enervision_test
CREATE EXTENSION IF NOT EXISTS timescaledb;
+47
View File
@@ -0,0 +1,47 @@
# Piege : PGDATA de l'image timescaledb-ha vaut /home/postgres/pgdata/data, pas le chemin
# habituel de l'image postgres. Monte ailleurs, le volume ne retient rien, sans erreur.
# Piege : db/init est monte fichier par fichier. Monter le dossier masquerait les scripts
# d'init de l'image, dont timescaledb-tune. Ajouter un fichier impose une ligne ici.
name: enervision
services:
db:
image: timescale/timescaledb-ha:pg17
environment:
POSTGRES_USER: ${POSTGRES_USER:?}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
POSTGRES_DB: ${POSTGRES_DB:?}
TIMESCALEDB_TELEMETRY: ${TIMESCALEDB_TELEMETRY:-off}
ports:
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- pgdata:/home/postgres/pgdata/data
- ./db/init/100-extensions.sql:/docker-entrypoint-initdb.d/100-extensions.sql:ro
- ./db/init/110-test-database.sql:/docker-entrypoint-initdb.d/110-test-database.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 12
start_period: 40s
restart: unless-stopped
backend:
build: ./apps/backend
depends_on:
db:
condition: service_healthy
environment:
APP_ENV: ${APP_ENV:-local}
APP_DEBUG: ${APP_DEBUG:-false}
APP_LOG_LEVEL: ${APP_LOG_LEVEL:-INFO}
APP_SECRET_KEY: ${APP_SECRET_KEY:?}
APP_CORS_ORIGINS: ${APP_CORS_ORIGINS:-http://localhost:4200}
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
ports:
- "${BACKEND_PORT:-8000}:8000"
restart: unless-stopped
volumes:
pgdata:
+66
View File
@@ -0,0 +1,66 @@
# 0001 - PostgreSQL avec l'extension TimescaleDB
- Statut : accepte
- Date : 2026-09-14
## Contexte
EnerVision collecte, stocke et restitue des series temporelles energetiques sur une machine
on-premise. La charge est dominee par des insertions horodatees en flux et par des lectures
agregees sur des fenetres de temps. Airflow produira des agregations continues, Grafana lira
les memes donnees, et l'API FastAPI les exposera.
Un SGBD relationnel generaliste sait faire, mais degrade a mesure que la table de mesures
grossit : les index se fragmentent, les balayages de fenetre deviennent couteux, et il faut
ecrire a la main le partitionnement, la retention et les agregats pre-calcules.
## Decision
PostgreSQL 17 avec l'extension TimescaleDB, servie en local par l'image
`timescale/timescaledb-ha:pg17`.
PostgreSQL reste une base relationnelle standard : un seul SGBD pour les donnees metier et
les mesures, un seul dialecte SQL, un seul pilote (`asyncpg`), et l'outillage habituel.
TimescaleDB ajoute le partitionnement automatique, les agregations continues et les
politiques de retention sans changer de moteur.
L'image `-ha` plutot que l'image alpine : elle embarque `timescaledb_toolkit`, `postgis` et
`pgvector`. Le toolkit porte les fonctions de comblement de trous et d'analyse de series dont
l'ETL aura besoin, et changer d'image plus tard imposerait une reinitialisation du volume.
PG17 plutot que PG18 : c'est la version la mieux couverte par Airflow et Grafana a ce jour.
## Frontiere entre `db/` et `apps/backend/alembic/`
C'est la regle que ce document existe surtout pour fixer.
- `db/init/` : bootstrap joue **une seule fois**, a la premiere initialisation du conteneur.
Extensions, bases annexes. Ne rejoue jamais sur un volume existant.
- `db/migrations/` : SQL versionne qui ne decoule pas du schema applicatif, typiquement les
politiques de retention et de compression TimescaleDB.
- `apps/backend/alembic/` : le schema expose par l'API, et lui seul. C'est `Base.metadata`
qui fait foi.
Une hypertable relevera des deux : Alembic cree la table, et le `create_hypertable()` vit
dans la meme revision Alembic, parce que separer les deux rendrait le schema irreproductible
depuis un seul `alembic upgrade head`.
## Consequences
- Le projet se lie a une extension, donc a un hebergement qui l'autorise. C'est acquis
puisque le deploiement est on-premise.
- `CREATE EXTENSION` demande le superutilisateur : cela reste un acte de bootstrap, pas une
migration applicative.
- Un bootstrap saute ne se voit pas au demarrage de l'API. Deux gardes couvrent ce cas :
`/api/v1/health/ready` repond 503 si l'extension est absente, et la premiere revision
Alembic refuse de s'appliquer.
- L'image `-ha` pese environ 1 Go, a telecharger une fois par poste.
## Alternatives ecartees
- **PostgreSQL nu, partitionnement manuel** : faisable, mais il faudrait reecrire ce que
TimescaleDB fournit, et le maintenir.
- **InfluxDB** : tres bon sur la serie temporelle, mais imposerait un second SGBD pour le
relationnel, donc deux dialectes, deux sauvegardes et des jointures applicatives.
- **ClickHouse** : taille pour un volume analytique que le projet n'atteindra pas, et moins
a l'aise sur les ecritures unitaires frequentes du flux d'ingestion.
+17 -1
View File
@@ -1,6 +1,22 @@
# Infrastructure # Infrastructure
Provisionnement Terraform de la machine on-premise. Non initialise, voir le ticket dedie. Provisionnement Terraform de la machine on-premise (serveur physique, accessible en SSH).
- `terraform/modules` : modules reutilisables. - `terraform/modules` : modules reutilisables.
- `k3s` : installe un cluster k3s single-node sur une machine distante via SSH
(script officiel `get.k3s.io`) et rapatrie le kubeconfig en local.
- `terraform/environments/<env>` : racines Terraform, une par environnement. - `terraform/environments/<env>` : racines Terraform, une par environnement.
- `dev` : instancie le module `k3s` sur le serveur de l'ecole.
- `prod` : non initialise, voir le ticket dedie.
## Usage (environments/dev)
```bash
cd infra/terraform/environments/dev
cp terraform.tfvars.example terraform.tfvars # renseigner ssh_host / ssh_private_key_path
terraform init
terraform apply
```
Le kubeconfig est ecrit localement au chemin defini par `kubeconfig_output_path`
(par defaut `./kubeconfig`, ignore par git).
+11
View File
@@ -0,0 +1,11 @@
module "k3s" {
source = "../../modules/k3s"
ssh_host = var.ssh_host
ssh_port = var.ssh_port
ssh_user = var.ssh_user
ssh_private_key_path = var.ssh_private_key_path
k3s_version = var.k3s_version
k3s_disable_components = var.k3s_disable_components
kubeconfig_output_path = var.kubeconfig_output_path
}
@@ -0,0 +1,9 @@
output "kubeconfig_path" {
description = "Chemin local du kubeconfig recupere apres installation."
value = module.k3s.kubeconfig_path
}
output "node_host" {
description = "Adresse du serveur sur lequel k3s est installe."
value = module.k3s.node_host
}
@@ -0,0 +1,8 @@
ssh_host = "10.0.0.10"
ssh_port = 22
ssh_user = "root"
ssh_private_key_path = "~/.ssh/id_ed25519_enervision"
# Epingler une version reelle avant apply : https://github.com/k3s-io/k3s/releases
k3s_version = "v1.31.5+k3s1"
k3s_disable_components = ["traefik"]
kubeconfig_output_path = "./kubeconfig"
@@ -0,0 +1,39 @@
variable "ssh_host" {
type = string
description = "Adresse IP ou nom d'hote du serveur on-premise de l'ecole."
}
variable "ssh_port" {
type = number
description = "Port SSH du serveur."
default = 22
}
variable "ssh_user" {
type = string
description = "Utilisateur SSH utilise pour l'installation."
default = "root"
}
variable "ssh_private_key_path" {
type = string
description = "Chemin local vers la cle privee SSH."
sensitive = true
}
variable "k3s_version" {
type = string
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
}
variable "k3s_disable_components" {
type = list(string)
description = "Composants embarques k3s a desactiver."
default = ["traefik"]
}
variable "kubeconfig_output_path" {
type = string
description = "Chemin local ou ecrire le kubeconfig recupere apres installation."
default = "./kubeconfig"
}
@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.7"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
backend "local" {
path = "terraform.tfstate"
}
}
+55
View File
@@ -0,0 +1,55 @@
locals {
sudo_prefix = var.ssh_user == "root" ? "" : "sudo "
install_env = "INSTALL_K3S_VERSION=${var.k3s_version} "
disable_flags = join(" ", [for c in var.k3s_disable_components : "--disable=${c}"])
kubeconfig_cmd = "${local.sudo_prefix}cat /etc/rancher/k3s/k3s.yaml"
}
resource "null_resource" "k3s_install" {
triggers = {
ssh_host = var.ssh_host
k3s_version = var.k3s_version
disable_components = join(",", var.k3s_disable_components)
}
connection {
type = "ssh"
host = var.ssh_host
port = var.ssh_port
user = var.ssh_user
private_key = file(var.ssh_private_key_path)
}
provisioner "remote-exec" {
inline = [
"${local.sudo_prefix}sh -c 'curl -sfL https://get.k3s.io | ${local.install_env}sh -s - server ${local.disable_flags}'",
"until ${local.sudo_prefix}test -f /etc/rancher/k3s/k3s.yaml; do sleep 2; done",
]
}
# Le kubeconfig est lu via sudo (fetch_kubeconfig), pas besoin de --write-kubeconfig-mode :
# il reste 600/root par defaut, ce qui evite d'exposer les droits cluster-admin a tout utilisateur local.
provisioner "remote-exec" {
when = destroy
on_failure = continue
inline = [
"${local.sudo_prefix}sh -c 'test -x /usr/local/bin/k3s-uninstall.sh && /usr/local/bin/k3s-uninstall.sh || true'",
]
}
}
resource "null_resource" "fetch_kubeconfig" {
depends_on = [null_resource.k3s_install]
triggers = {
install_id = null_resource.k3s_install.id
}
provisioner "local-exec" {
interpreter = ["bash", "-c"]
command = <<-EOT
ssh -i "${var.ssh_private_key_path}" -p ${var.ssh_port} -o StrictHostKeyChecking=accept-new ${var.ssh_user}@${var.ssh_host} '${local.kubeconfig_cmd}' \
| sed 's/127.0.0.1/${var.ssh_host}/' > "${var.kubeconfig_output_path}"
EOT
}
}
+9
View File
@@ -0,0 +1,9 @@
output "kubeconfig_path" {
description = "Chemin local du kubeconfig recupere apres installation."
value = var.kubeconfig_output_path
}
output "node_host" {
description = "Adresse de la machine sur laquelle k3s est installe."
value = var.ssh_host
}
+43
View File
@@ -0,0 +1,43 @@
variable "ssh_host" {
type = string
description = "Adresse IP ou nom d'hote de la machine on-premise cible."
}
variable "ssh_port" {
type = number
description = "Port SSH de la machine cible."
default = 22
}
variable "ssh_user" {
type = string
description = "Utilisateur SSH. Si different de root, les commandes d'installation sont prefixees par sudo."
default = "root"
}
variable "ssh_private_key_path" {
type = string
description = "Chemin local vers la cle privee SSH utilisee pour se connecter a la machine cible."
sensitive = true
}
variable "k3s_version" {
type = string
description = "Version k3s a epingler pour un deploiement reproductible (ex: v1.31.5+k3s1). Voir https://github.com/k3s-io/k3s/releases."
validation {
condition = length(trimspace(var.k3s_version)) > 0
error_message = "k3s_version doit etre epinglee explicitement, pas de valeur vide (sinon k3s.io installerait la derniere version a chaque run, non reproductible)."
}
}
variable "k3s_disable_components" {
type = list(string)
description = "Composants embarques a desactiver a l'installation (ex: traefik, servicelb)."
default = ["traefik"]
}
variable "kubeconfig_output_path" {
type = string
description = "Chemin local ou ecrire le kubeconfig recupere apres installation."
}
+10
View File
@@ -0,0 +1,10 @@
terraform {
required_version = ">= 1.7"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
-1
View File
@@ -1 +0,0 @@
coucou