From 20e7374d90b9c6fdded2e4cf3c62a3061d5c0f78 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Mon, 14 Sep 2026 14:19:25 +0200 Subject: [PATCH] feat(backend): verifie l extension TimescaleDB sur la sonde de disponibilite /api/v1/health/ready interrogeait la base par un SELECT 1, qui ne distingue pas un PostgreSQL nu d'un PostgreSQL avec TimescaleDB. La sonde lit desormais pg_extension et repond 503 si l'extension manque, cas qui survient quand db/init n'a pas ete joue. - Premiere revision Alembic : aucune table, une garde qui refuse de s'appliquer sans l'extension. - Tests du chemin nominal et de l'extension absente, plus un test marque `integration` contre la vraie base. pytest ecarte ce marqueur par defaut pour que make check reste jouable sans Docker. - conftest recycle l'engine entre les tests : get_engine est lru_cache alors que pytest-asyncio ouvre une boucle par test, et les connexions asyncpg sont liees a leur boucle. --- apps/backend/.env.example | 2 +- apps/backend/README.md | 14 ++++- ...4f094_socle_garde_extension_timescaledb.py | 37 ++++++++++++++ apps/backend/app/api/v1/endpoints/health.py | 16 ++++-- apps/backend/app/schemas/health.py | 1 + apps/backend/pyproject.toml | 3 +- apps/backend/tests/api/test_health.py | 51 ++++++++++++++++++- apps/backend/tests/conftest.py | 14 ++++- 8 files changed, 130 insertions(+), 8 deletions(-) create mode 100644 apps/backend/alembic/versions/5353c0e4f094_socle_garde_extension_timescaledb.py 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..d70b3ca 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,13 @@ 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 ``` +`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 +81,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 +94,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/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 711bf89..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")) - except (SQLAlchemyError, OSError): + 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..fee4044 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -79,7 +79,8 @@ disallow_untyped_defs = false [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" -addopts = "-q --strict-markers --cov=app --cov-report=term-missing" +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"] diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index 6a2f938..f0d647f 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -20,6 +20,44 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: } +async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: AsyncClient) -> None: + class ReadySession: + async def scalar(self, *_: object, **__: object) -> str: + return "2.22.1" + + async def override() -> AsyncIterator[ReadySession]: + yield ReadySession() + + app.dependency_overrides[get_session] = override + + 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( + app: FastAPI, client: AsyncClient +) -> None: + class SessionWithoutExtension: + async def scalar(self, *_: object, **__: object) -> None: + return None + + async def override() -> AsyncIterator[SessionWithoutExtension]: + yield SessionWithoutExtension() + + app.dependency_overrides[get_session] = override + + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 503 + assert response.json()["detail"] == "Extension TimescaleDB absente" + + @pytest.mark.parametrize( "failure", [ @@ -32,7 +70,7 @@ async def test_readiness_returns_503_when_database_is_unreachable( app: FastAPI, client: AsyncClient, failure: Exception ) -> None: class UnreachableSession: - async def execute(self, *_: object, **__: object) -> None: + async def scalar(self, *_: object, **__: object) -> None: raise failure async def override() -> AsyncIterator[UnreachableSession]: @@ -49,3 +87,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..ac0e89b 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -6,6 +6,7 @@ from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from app.core.config import get_settings +from app.db.session import get_engine, get_session_factory from app.main import create_app @@ -13,13 +14,24 @@ from app.main import create_app def environment() -> Iterator[None]: os.environ.setdefault("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()