diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a5fba5a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 777b2b7..bb3dca3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ venv/ .coverage coverage.xml htmlcov/ +test-results/ dist/ build/ *.egg-info/ diff --git a/Makefile b/Makefile index 71dea97..81c3e6d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 22b01ab..3302c64 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,13 @@ 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 (k3s single-node) | `infra/terraform` | Initialise | | CI/CD | GitHub Actions | `.github/workflows` | A initialiser | | Monitoring | Prometheus, Grafana, Alertmanager | `monitoring` | A initialiser | -Le backend et l'infrastructure (Terraform/k3s) sont initialises. Les autres dossiers +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 @@ portent l'arborescence et un README de cadrage, leur contenu fait l'objet d'un t 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`. diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 258db03..cd96463 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -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 diff --git a/apps/backend/README.md b/apps/backend/README.md index bd1c6fa..5d940b8 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -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 diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md new file mode 100644 index 0000000..f794421 --- /dev/null +++ b/apps/backend/TESTING.md @@ -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___when_`. +- `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 +``` diff --git a/apps/backend/alembic/versions/5353c0e4f094_socle_garde_extension_timescaledb.py b/apps/backend/alembic/versions/5353c0e4f094_socle_garde_extension_timescaledb.py new file mode 100644 index 0000000..de14bd3 --- /dev/null +++ b/apps/backend/alembic/versions/5353c0e4f094_socle_garde_extension_timescaledb.py @@ -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 diff --git a/apps/backend/app/api/v1/endpoints/health.py b/apps/backend/app/api/v1/endpoints/health.py index b3c8523..be3abf8 100644 --- a/apps/backend/app/api/v1/endpoints/health.py +++ b/apps/backend/app/api/v1/endpoints/health.py @@ -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) diff --git a/apps/backend/app/schemas/health.py b/apps/backend/app/schemas/health.py index d1eb845..e4ec86e 100644 --- a/apps/backend/app/schemas/health.py +++ b/apps/backend/app/schemas/health.py @@ -13,3 +13,4 @@ class LivenessStatus(BaseModel): class ReadinessStatus(BaseModel): status: Literal["ready"] database: Literal["reachable"] + timescaledb: str diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 87c916a..1c27c08 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -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 diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index 6a2f938..a7a61b6 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -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"] diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index d0d5873..4560b75 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -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 diff --git a/db/init/.gitkeep b/apps/backend/tests/core/__init__.py similarity index 100% rename from db/init/.gitkeep rename to apps/backend/tests/core/__init__.py diff --git a/docs/adr/.gitkeep b/apps/backend/tests/db/__init__.py similarity index 100% rename from docs/adr/.gitkeep rename to apps/backend/tests/db/__init__.py diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py new file mode 100644 index 0000000..05ba3fc --- /dev/null +++ b/apps/backend/tests/factories.py @@ -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}) diff --git a/apps/backend/tests/repositories/__init__.py b/apps/backend/tests/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/services/__init__.py b/apps/backend/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/frontend/TESTING.md b/apps/frontend/TESTING.md new file mode 100644 index 0000000..e23ed7a --- /dev/null +++ b/apps/frontend/TESTING.md @@ -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` diff --git a/apps/frontend/angular.json b/apps/frontend/angular.json index 3140772..ddf87a3 100644 --- a/apps/frontend/angular.json +++ b/apps/frontend/angular.json @@ -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" + } + ] + ] + } } } } diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 8f4408b..5ba6595 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -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", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 7369db4..552e346 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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", diff --git a/apps/frontend/test-results/junit.xml b/apps/frontend/test-results/junit.xml new file mode 100644 index 0000000..28e5ba4 --- /dev/null +++ b/apps/frontend/test-results/junit.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/db/README.md b/db/README.md index e80cb3c..fd62d6f 100644 --- a/db/README.md +++ b/db/README.md @@ -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. diff --git a/db/init/100-extensions.sql b/db/init/100-extensions.sql new file mode 100644 index 0000000..2326d5d --- /dev/null +++ b/db/init/100-extensions.sql @@ -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; diff --git a/db/init/110-test-database.sql b/db/init/110-test-database.sql new file mode 100644 index 0000000..0f47b63 --- /dev/null +++ b/db/init/110-test-database.sql @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d8569c9 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/adr/0001-postgresql-timescaledb.md b/docs/adr/0001-postgresql-timescaledb.md new file mode 100644 index 0000000..4ed562b --- /dev/null +++ b/docs/adr/0001-postgresql-timescaledb.md @@ -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.