Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1103c6e1a6 | ||
|
|
b910e747ec | ||
|
|
4692f1d604 | ||
|
|
239efc8ee6 | ||
|
|
0ca429ff3d | ||
|
|
a2727f9b5a | ||
|
|
6aeaca8ed1 | ||
|
|
08ad3bbe34 | ||
|
|
50dfa72c9d | ||
|
|
d58647cad4 | ||
|
|
d14b3afc8e | ||
|
|
c95f4d3851 | ||
|
|
8f237f6d6f | ||
|
|
3db4419bdf | ||
|
|
3ca1866e93 | ||
|
|
98ec01c847 | ||
|
|
552391c9bd | ||
|
|
34890b2b04 | ||
|
|
06a8ae42d2 | ||
|
|
1fbacf2fa3 | ||
|
|
0be2418e02 | ||
|
|
6bc2c3793f | ||
|
|
f4d05a8ca9 | ||
|
|
20e7374d90 | ||
|
|
351e928309 | ||
|
|
91f4f007d3 | ||
|
|
4de5fb0935 |
@@ -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
@@ -9,6 +9,7 @@ venv/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
test-results/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
@@ -23,7 +24,7 @@ yarn-error.log*
|
||||
|
||||
# Terraform
|
||||
.terraform/
|
||||
.terraform.lock.hcl
|
||||
# .terraform.lock.hcl est versionne (pas ignore) pour figer les versions de provider entre contributeurs/CI
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
@@ -32,6 +33,9 @@ override.tf
|
||||
override.tf.json
|
||||
*_override.tf
|
||||
*_override.tf.json
|
||||
*.tfvars
|
||||
!*.tfvars.example
|
||||
kubeconfig
|
||||
|
||||
# Airflow
|
||||
etl/airflow/logs/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
BACKEND := apps/backend
|
||||
|
||||
.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
|
||||
@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
|
||||
cd $(BACKEND) && uv run mypy app
|
||||
|
||||
test: ## Execute les tests backend
|
||||
cd $(BACKEND) && uv run pytest
|
||||
test: ## Execute les tests backend ne demandant pas de base
|
||||
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
|
||||
|
||||
docker-build: ## Construit l'image du 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
|
||||
|
||||
@@ -9,14 +9,14 @@ series temporelles energetiques, deployee sur une machine on-premise.
|
||||
|------------|-------------------------------------|---------------------|---------------|
|
||||
| Backend | FastAPI, Python 3.14 | `apps/backend` | Initialise |
|
||||
| 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 |
|
||||
| Infra | Terraform | `infra/terraform` | A initialiser |
|
||||
| Infra | Terraform (k3s single-node) | `infra/terraform` | Initialise |
|
||||
| CI/CD | GitHub Actions | `.github/workflows` | A initialiser |
|
||||
| Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser |
|
||||
|
||||
Seul le backend est initialise a ce stade. Les autres dossiers portent l'arborescence et
|
||||
un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
||||
Le backend, la base et l'infrastructure (Terraform/k3s) sont initialises a ce stade. Les autres dossiers
|
||||
portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un ticket dedie.
|
||||
|
||||
## 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.
|
||||
|
||||
```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 migrate # applique les migrations Alembic
|
||||
make dev # API sur http://localhost:8000, docs sur /docs
|
||||
make check # lint + typage + tests
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
- 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.
|
||||
- Toute decision structurante donne lieu a un ADR dans `docs/adr`.
|
||||
|
||||
@@ -3,4 +3,4 @@ APP_DEBUG=true
|
||||
APP_LOG_LEVEL=INFO
|
||||
APP_SECRET_KEY=change_me
|
||||
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
@@ -22,6 +22,9 @@ uv sync --all-groups
|
||||
`APP_SECRET_KEY` et `DATABASE_URL` n'ont pas de valeur par defaut : l'application refuse
|
||||
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
|
||||
|
||||
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 mypy app # typage strict
|
||||
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 :
|
||||
aucune configuration n'est lue a l'import, ce qui rend les tests et les migrations
|
||||
independants de l'environnement.
|
||||
@@ -73,7 +84,7 @@ Le sens de dependance est unique : `endpoints` vers `services` vers `repositorie
|
||||
| Route | Role |
|
||||
|------------------------|-------------------------------------------------|
|
||||
| `/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 |
|
||||
| `/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`.
|
||||
|
||||
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
|
||||
|
||||
Build multi-stage, dependances resolues par uv depuis `uv.lock`, execution sous un
|
||||
|
||||
@@ -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
|
||||
@@ -9,6 +9,8 @@ from app.schemas.health import LivenessStatus, ReadinessStatus
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
TIMESCALEDB_VERSION = text("SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'")
|
||||
|
||||
|
||||
@router.get("/live", summary="Sonde de vivacite")
|
||||
async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
@@ -23,11 +25,19 @@ async def liveness(settings: SettingsDep) -> LivenessStatus:
|
||||
@router.get("/ready", summary="Sonde de disponibilite")
|
||||
async def readiness(session: SessionDep) -> ReadinessStatus:
|
||||
try:
|
||||
await session.execute(text("SELECT 1"))
|
||||
version: str | None = await session.scalar(TIMESCALEDB_VERSION)
|
||||
except SQLAlchemyError, OSError:
|
||||
logger.exception("Base de donnees injoignable")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Base de donnees injoignable",
|
||||
) 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)
|
||||
|
||||
@@ -13,3 +13,4 @@ class LivenessStatus(BaseModel):
|
||||
class ReadinessStatus(BaseModel):
|
||||
status: Literal["ready"]
|
||||
database: Literal["reachable"]
|
||||
timescaledb: str
|
||||
|
||||
@@ -79,8 +79,14 @@ disallow_untyped_defs = false
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
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]
|
||||
source = ["app"]
|
||||
omit = ["app/main.py", "alembic/*"]
|
||||
branch = true
|
||||
omit = ["alembic/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.db.session import get_session
|
||||
|
||||
|
||||
async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None:
|
||||
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(
|
||||
"failure",
|
||||
[
|
||||
@@ -29,16 +52,9 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None:
|
||||
ids=["erreur_sqlalchemy", "erreur_reseau_asyncpg"],
|
||||
)
|
||||
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:
|
||||
class UnreachableSession:
|
||||
async def execute(self, *_: object, **__: object) -> None:
|
||||
raise failure
|
||||
|
||||
async def override() -> AsyncIterator[UnreachableSession]:
|
||||
yield UnreachableSession()
|
||||
|
||||
app.dependency_overrides[get_session] = override
|
||||
fake_session(failure=failure)
|
||||
|
||||
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"])
|
||||
async def test_technical_endpoints_are_served(client: AsyncClient, path: str) -> None:
|
||||
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"]
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from collections.abc import AsyncIterator, Callable, Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 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")
|
||||
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(
|
||||
"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()
|
||||
yield
|
||||
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
|
||||
def app() -> FastAPI:
|
||||
return create_app()
|
||||
@@ -30,3 +54,21 @@ async def client(app: FastAPI) -> AsyncIterator[AsyncClient]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as 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
|
||||
|
||||
@@ -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})
|
||||
@@ -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`
|
||||
@@ -77,7 +77,24 @@
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+201
@@ -21,6 +21,7 @@
|
||||
"@angular/build": "^22.1.8",
|
||||
"@angular/cli": "^22.1.8",
|
||||
"@angular/compiler-cli": "^22.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~6.0.2",
|
||||
@@ -733,6 +734,16 @@
|
||||
"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": {
|
||||
"version": "2.4.2",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
@@ -3943,6 +3985,18 @@
|
||||
"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": {
|
||||
"version": "2.11.23",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz",
|
||||
@@ -5077,6 +5131,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"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_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": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
@@ -5382,6 +5453,45 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "6.2.12",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
|
||||
@@ -5886,6 +5996,84 @@
|
||||
"@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": {
|
||||
"version": "1.1.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
"test": "ng test",
|
||||
"test:ci": "ng test --watch=false"
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.19.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"@angular/build": "^22.1.8",
|
||||
"@angular/cli": "^22.1.8",
|
||||
"@angular/compiler-cli": "^22.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
@@ -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 > should create the app" time="0.0527847">
|
||||
</testcase>
|
||||
<testcase classname="src/app/app.spec.ts" name="App > should render title" time="0.015831">
|
||||
</testcase>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
+30
-1
@@ -1,6 +1,7 @@
|
||||
# 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.
|
||||
- `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
|
||||
`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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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:
|
||||
@@ -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
@@ -1,6 +1,22 @@
|
||||
# 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.
|
||||
- `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.
|
||||
- `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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
terraform {
|
||||
required_version = ">= 1.7"
|
||||
|
||||
required_providers {
|
||||
null = {
|
||||
source = "hashicorp/null"
|
||||
version = "~> 3.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user