From 452cfdef85341005f1614e5cdafbe941e2cfbae1 Mon Sep 17 00:00:00 2001 From: Meryemel-gham Date: Mon, 21 Sep 2026 09:36:10 +0200 Subject: [PATCH] test(etl): corrige les points bloquants de la revue API Mock --- .../backend/tests/etl/test_mock_api_import.py | 380 ++++++++++++++++-- docker-compose.yml | 8 +- 2 files changed, 354 insertions(+), 34 deletions(-) diff --git a/apps/backend/tests/etl/test_mock_api_import.py b/apps/backend/tests/etl/test_mock_api_import.py index c51a153..2b4c07c 100644 --- a/apps/backend/tests/etl/test_mock_api_import.py +++ b/apps/backend/tests/etl/test_mock_api_import.py @@ -1,6 +1,9 @@ import json +import sys from datetime import datetime +from types import SimpleNamespace from typing import Any +from unittest.mock import AsyncMock, MagicMock import httpx import pytest @@ -8,6 +11,7 @@ from httpx import AsyncClient, MockTransport, Request, Response from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +import app.etl.mock_api_import as mock_api_import from app.etl.mock_api_import import ( READING_INSERT, SOURCE_HISTORY, @@ -15,6 +19,7 @@ from app.etl.mock_api_import import ( build_reading_row, fetch_readings, fetch_sites, + upsert_sites, ) @@ -49,6 +54,7 @@ def make_reading() -> dict[str, Any]: async def test_fetch_sites_returns_sites() -> None: def handler(request: Request) -> Response: assert request.url.path == "/api/v1/sites" + return Response( status_code=200, json=[make_site()], @@ -163,7 +169,9 @@ def test_build_reading_row_respects_database_contract() -> None: assert row["source"] == "api_history" assert row["dataset_id"] is None - assert row["timestamp"] == datetime.fromisoformat("2024-06-15T12:00:00+00:00") + assert row["timestamp"] == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00" + ) assert row["consumption_kw"] == 87.34 assert row["consumption_kwh"] == 87.34 @@ -234,6 +242,342 @@ def test_build_reading_batch_transforms_all_readings() -> None: assert rows[1]["consumption_kw"] == 90.5 +def test_create_mock_api_client_requires_credentials( + monkeypatch: pytest.MonkeyPatch, +) -> None: + settings = SimpleNamespace( + mock_api_username=None, + mock_api_password=None, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + with pytest.raises( + ValueError, + match="Les identifiants de l'API Mock ne sont pas configurés", + ): + mock_api_import.create_mock_api_client() + + +async def test_create_mock_api_client_uses_configuration( + monkeypatch: pytest.MonkeyPatch, +) -> None: + password = MagicMock() + password.get_secret_value.return_value = "test-password" + + settings = SimpleNamespace( + mock_api_base_url="https://mock.test/", + mock_api_username="test-user", + mock_api_password=password, + mock_api_timeout_seconds=10.0, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: settings, + ) + + client = mock_api_import.create_mock_api_client() + + try: + assert str(client.base_url) == "https://mock.test" + assert client.timeout.connect == 10.0 + finally: + await client.aclose() + + +async def test_upsert_sites_with_empty_list_does_nothing() -> None: + connection = AsyncMock() + + await upsert_sites( + connection, + [], + ) + + connection.execute.assert_not_awaited() + + +async def test_import_mock_api_history_dry_run_does_not_write( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://unused", + ), + ) + + create_engine_mock = MagicMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=True, + ) + + create_engine_mock.assert_not_called() + + +async def test_import_mock_api_history_loads_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(request: Request) -> Response: + if request.url.path == "/api/v1/sites": + return Response( + status_code=200, + json=[make_site()], + ) + + if request.url.path == "/api/v1/readings": + return Response( + status_code=200, + json=[make_reading()], + ) + + return Response(status_code=404) + + transport = MockTransport(handler) + + client = AsyncClient( + transport=transport, + base_url="https://mock.test", + ) + + monkeypatch.setattr( + mock_api_import, + "create_mock_api_client", + lambda: client, + ) + + monkeypatch.setattr( + mock_api_import, + "get_settings", + lambda: SimpleNamespace( + database_url="postgresql+asyncpg://test:test@localhost/test", + ), + ) + + connection = AsyncMock() + + transaction_context = MagicMock() + transaction_context.__aenter__ = AsyncMock( + return_value=connection, + ) + transaction_context.__aexit__ = AsyncMock( + return_value=None, + ) + + engine = MagicMock() + engine.begin.return_value = transaction_context + engine.dispose = AsyncMock() + + create_engine_mock = MagicMock( + return_value=engine, + ) + + upsert_sites_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "create_async_engine", + create_engine_mock, + ) + + monkeypatch.setattr( + mock_api_import, + "upsert_sites", + upsert_sites_mock, + ) + + await mock_api_import.import_mock_api_history( + start_time=datetime.fromisoformat("2024-06-15T12:00:00"), + end_time=datetime.fromisoformat("2024-06-15T13:00:00"), + limit=60, + dry_run=False, + ) + + create_engine_mock.assert_called_once_with( + "postgresql+asyncpg://test:test@localhost/test", + pool_pre_ping=True, + ) + + upsert_sites_mock.assert_awaited_once_with( + connection, + [make_site()], + ) + + connection.execute.assert_awaited_once() + engine.dispose.assert_awaited_once() + + +def test_parse_datetime_accepts_z_suffix() -> None: + result = mock_api_import.parse_datetime( + "2024-06-15T12:00:00Z", + ) + + assert result == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + + +def test_parse_args_reads_cli_parameters( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + "--dry-run", + ], + ) + + args = mock_api_import.parse_args() + + assert args.start_time == datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + assert args.end_time == datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + assert args.limit == 60 + assert args.dry_run is True + + +def test_main_rejects_limit_out_of_bounds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T12:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "0", + ], + ) + + with pytest.raises( + ValueError, + match="--limit doit être compris entre 1 et 1000", + ): + mock_api_import.main() + + +def test_main_rejects_invalid_period( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + sys, + "argv", + [ + "mock_api_import", + "--start-time", + "2024-06-15T14:00:00Z", + "--end-time", + "2024-06-15T13:00:00Z", + "--limit", + "60", + ], + ) + + with pytest.raises( + ValueError, + match="--start-time doit être antérieur à --end-time", + ): + mock_api_import.main() + + +def test_main_runs_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + start_time = datetime.fromisoformat( + "2024-06-15T12:00:00+00:00", + ) + end_time = datetime.fromisoformat( + "2024-06-15T13:00:00+00:00", + ) + + import_mock = AsyncMock() + + monkeypatch.setattr( + mock_api_import, + "parse_args", + lambda: SimpleNamespace( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ), + ) + + monkeypatch.setattr( + mock_api_import, + "import_mock_api_history", + import_mock, + ) + + mock_api_import.main() + + import_mock.assert_awaited_once_with( + start_time=start_time, + end_time=end_time, + limit=60, + dry_run=True, + ) + + @pytest.mark.integration async def test_reading_insert_is_idempotent( session: AsyncSession, @@ -241,35 +585,11 @@ async def test_reading_insert_is_idempotent( reading = make_reading() row = build_reading_row(reading) - await session.execute( - text( - """ - INSERT INTO site ( - site_id, - site_type, - site_name, - location, - capacity_kw, - status - ) - VALUES ( - :site_id, - :site_type, - :site_name, - :location, - :capacity_kw, - :status - ) - ON CONFLICT (site_id) - DO UPDATE SET - site_type = EXCLUDED.site_type, - site_name = EXCLUDED.site_name, - location = EXCLUDED.location, - capacity_kw = EXCLUDED.capacity_kw, - status = EXCLUDED.status - """ - ), - make_site(), + connection = await session.connection() + + await upsert_sites( + connection, + [make_site()], ) await session.execute( diff --git a/docker-compose.yml b/docker-compose.yml index 91e315e..12b5c96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,10 +50,10 @@ services: 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} - - APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:?} - APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:?} - APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:?} + + APP_MOCK_API_BASE_URL: ${APP_MOCK_API_BASE_URL:-https://api-mock.charlieandre.fr} + APP_MOCK_API_USERNAME: ${APP_MOCK_API_USERNAME:-} + APP_MOCK_API_PASSWORD: ${APP_MOCK_API_PASSWORD:-} APP_MOCK_API_TIMEOUT_SECONDS: ${APP_MOCK_API_TIMEOUT_SECONDS:-10} APP_FRONTEND_RESET_PASSWORD_URL: ${APP_FRONTEND_RESET_PASSWORD_URL:-http://localhost:4200/reset-password}