diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index e152c5e..6a8a770 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -11,13 +11,14 @@ WORKDIR /app RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project --no-dev + uv sync --locked --no-install-project --no-dev --no-build +# Le projet lui-meme n'est pas installe (pas de second `uv sync`) : il tourne depuis /app, le +# repertoire de travail, et rien ne lit ses metadonnees. L'installer imposerait de le construire +# (backend hatchling), donc de retirer `--no-build` de l'etape ci-dessus, qui garantit que +# l'installation des dependances n'execute aucun script de build (regle Sonar docker:S8541). COPY . /app -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-dev - FROM python:3.14-slim AS runtime diff --git a/apps/backend/tests/db/test_data_schema.py b/apps/backend/tests/db/test_data_schema.py index c564042..52295aa 100644 --- a/apps/backend/tests/db/test_data_schema.py +++ b/apps/backend/tests/db/test_data_schema.py @@ -112,8 +112,10 @@ async def test_duplicate_reading_is_rejected_when_key_matches( ) await data_connection.execute(statement) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): + async with savepoint: await data_connection.execute(statement) @@ -147,9 +149,12 @@ async def test_invalid_reading_is_rejected_when_constraints_fail( } values.update(changes) + statement = insert(Reading).values(**values) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): - await data_connection.execute(insert(Reading).values(**values)) + async with savepoint: + await data_connection.execute(statement) async def test_prediction_requires_period_when_energy_is_predicted( @@ -164,8 +169,10 @@ async def test_prediction_requires_period_when_energy_is_predicted( model_reference="test-model/1", ) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): + async with savepoint: await data_connection.execute(statement) @@ -212,21 +219,22 @@ async def test_alert_rejects_prediction_when_site_differs( ) ).scalar_one() + statement = insert(Alert).values( + source_alert_id=str(uuid4()), + site_id=other_site, + source="enervision", + timestamp=MOMENT, + type="spike", + severity="high", + message="Test", + prediction_id=prediction_id, + raw_data={}, + ) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): - await data_connection.execute( - insert(Alert).values( - source_alert_id=str(uuid4()), - site_id=other_site, - source="enervision", - timestamp=MOMENT, - type="spike", - severity="high", - message="Test", - prediction_id=prediction_id, - raw_data={}, - ) - ) + async with savepoint: + await data_connection.execute(statement) async def test_recommendation_is_unique_when_alert_and_rule_match( @@ -256,6 +264,8 @@ async def test_recommendation_is_unique_when_alert_and_rule_match( ) await data_connection.execute(statement) + savepoint = data_connection.begin_nested() + with pytest.raises(IntegrityError): - async with data_connection.begin_nested(): + async with savepoint: await data_connection.execute(statement) diff --git a/apps/backend/tests/etl/test_historical_import.py b/apps/backend/tests/etl/test_historical_import.py index 31f6e2d..2f3ea92 100644 --- a/apps/backend/tests/etl/test_historical_import.py +++ b/apps/backend/tests/etl/test_historical_import.py @@ -1,239 +1,245 @@ -import hashlib -import json - -import pandas as pd -import pytest - -from app.etl.historical_import import ( - SOURCE_NAME, - build_reading_batch, - classify_quality, - compute_sha256, - load_metadata, - normalize_timestamps, - validate_source, -) - - -def make_metadata() -> dict: - return { - "total_records": 2, - "sites": { - "SITE001": {}, - }, - } - - -def make_dataframe() -> pd.DataFrame: - return pd.DataFrame( - [ - { - "timestamp": "2023-01-01 00:00:00", - "site_id": "SITE001", - "site_type": "office", - "site_name": "Site 1", - "consumption_kwh": 10.5, - "consumption_euros": 2.5, - "temperature_celsius": 20.0, - "humidity_percent": 50.0, - "solar_irradiance_wm2": 0.0, - "hour": 0, - "day_of_week": 6, - "day_name": "Sunday", - "month": 1, - "is_weekend": True, - "is_working_hours": False, - }, - { - "timestamp": "2023-01-01 01:00:00", - "site_id": "SITE001", - "site_type": "office", - "site_name": "Site 1", - "consumption_kwh": 11.0, - "consumption_euros": 2.7, - "temperature_celsius": 19.5, - "humidity_percent": 52.0, - "solar_irradiance_wm2": 0.0, - "hour": 1, - "day_of_week": 6, - "day_name": "Sunday", - "month": 1, - "is_weekend": True, - "is_working_hours": False, - }, - ] - ) - - -def test_compute_sha256(tmp_path): - file_path = tmp_path / "dataset.csv" - content = b"hello-enervision" - - file_path.write_bytes(content) - - expected = hashlib.sha256(content).hexdigest() - - assert compute_sha256(file_path) == expected - - -def test_load_metadata(tmp_path): - metadata_path = tmp_path / "metadata.json" - - metadata = { - "total_records": 2, - "sites": { - "SITE001": {}, - }, - } - - metadata_path.write_text( - json.dumps(metadata), - encoding="utf-8", - ) - - assert load_metadata(metadata_path) == metadata - - -def test_validate_source_accepts_valid_dataset(): - frame = make_dataframe() - - validate_source( - frame, - make_metadata(), - ) - - -def test_validate_source_rejects_missing_column(): - frame = make_dataframe().drop(columns=["consumption_kwh"]) - - with pytest.raises( - ValueError, - match="Colonnes obligatoires absentes", - ): - validate_source( - frame, - make_metadata(), - ) - - -def test_validate_source_rejects_duplicates(): - frame = make_dataframe() - - frame.loc[1, "timestamp"] = frame.loc[ - 0, - "timestamp", - ] - - with pytest.raises( - ValueError, - match="doublons", - ): - validate_source( - frame, - make_metadata(), - ) - - -def test_validate_source_rejects_unknown_site(): - frame = make_dataframe() - - frame.loc[1, "site_id"] = "SITE999" - - with pytest.raises( - ValueError, - match="Sites incohérents", - ): - validate_source( - frame, - make_metadata(), - ) - - -def test_normalize_timestamps_adds_timezone(): - frame = make_dataframe() - - normalized = normalize_timestamps( - frame, - "UTC", - ) - - assert normalized["timestamp"].dt.tz is not None - - assert "_source_timestamp" in normalized.columns - - -def test_classify_quality_good(): - row = make_dataframe().iloc[0].to_dict() - - quality, reasons = classify_quality(row) - - assert quality == "good" - assert reasons == [] - - -def test_classify_quality_degraded_when_consumption_missing(): - row = make_dataframe().iloc[0].to_dict() - row["consumption_kwh"] = None - - quality, reasons = classify_quality(row) - - assert quality == "degraded" - - assert "missing:consumption_kwh" in reasons - - -def test_build_reading_batch_respects_database_contract(): - frame = normalize_timestamps( - make_dataframe(), - "UTC", - ) - - rows = build_reading_batch( - frame.iloc[:1], - dataset_id=3, - ) - - assert len(rows) == 1 - - row = rows[0] - - assert row["dataset_id"] == 3 - - # Important : - # contrainte ck_reading_dataset_source. - assert row["source"] == "csv" - assert SOURCE_NAME == "csv" - - # Important : - # contrainte ck_reading_imputation. - assert row["imputed_values"] is None - assert row["imputation_method"] is None - - assert row["data_quality"] == "good" - assert row["null_reasons"] == [] - - -def test_build_reading_batch_keeps_missing_values(): - frame = make_dataframe() - - frame.loc[0, "temperature_celsius"] = None - - frame = normalize_timestamps( - frame, - "UTC", - ) - - rows = build_reading_batch( - frame.iloc[:1], - dataset_id=3, - ) - - row = rows[0] - - assert row["temperature_celsius"] is None - - assert "missing:temperature_celsius" in row["null_reasons"] - - # RAW ingestion : aucune imputation. - assert row["imputed_values"] is None - assert row["imputation_method"] is None +import hashlib +import json + +import pandas as pd +import pytest + +from app.etl.historical_import import ( + SOURCE_NAME, + build_reading_batch, + classify_quality, + compute_sha256, + load_metadata, + normalize_timestamps, + validate_source, +) + + +def make_metadata() -> dict: + return { + "total_records": 2, + "sites": { + "SITE001": {}, + }, + } + + +def make_dataframe() -> pd.DataFrame: + return pd.DataFrame( + [ + { + "timestamp": "2023-01-01 00:00:00", + "site_id": "SITE001", + "site_type": "office", + "site_name": "Site 1", + "consumption_kwh": 10.5, + "consumption_euros": 2.5, + "temperature_celsius": 20.0, + "humidity_percent": 50.0, + "solar_irradiance_wm2": 0.0, + "hour": 0, + "day_of_week": 6, + "day_name": "Sunday", + "month": 1, + "is_weekend": True, + "is_working_hours": False, + }, + { + "timestamp": "2023-01-01 01:00:00", + "site_id": "SITE001", + "site_type": "office", + "site_name": "Site 1", + "consumption_kwh": 11.0, + "consumption_euros": 2.7, + "temperature_celsius": 19.5, + "humidity_percent": 52.0, + "solar_irradiance_wm2": 0.0, + "hour": 1, + "day_of_week": 6, + "day_name": "Sunday", + "month": 1, + "is_weekend": True, + "is_working_hours": False, + }, + ] + ) + + +def test_compute_sha256(tmp_path): + file_path = tmp_path / "dataset.csv" + content = b"hello-enervision" + + file_path.write_bytes(content) + + expected = hashlib.sha256(content).hexdigest() + + assert compute_sha256(file_path) == expected + + +def test_load_metadata(tmp_path): + metadata_path = tmp_path / "metadata.json" + + metadata = { + "total_records": 2, + "sites": { + "SITE001": {}, + }, + } + + metadata_path.write_text( + json.dumps(metadata), + encoding="utf-8", + ) + + assert load_metadata(metadata_path) == metadata + + +def test_validate_source_accepts_valid_dataset(): + frame = make_dataframe() + + validate_source( + frame, + make_metadata(), + ) + + +def test_validate_source_rejects_missing_column(): + frame = make_dataframe().drop(columns=["consumption_kwh"]) + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="Colonnes obligatoires absentes", + ): + validate_source( + frame, + metadata, + ) + + +def test_validate_source_rejects_duplicates(): + frame = make_dataframe() + + frame.loc[1, "timestamp"] = frame.loc[ + 0, + "timestamp", + ] + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="doublons", + ): + validate_source( + frame, + metadata, + ) + + +def test_validate_source_rejects_unknown_site(): + frame = make_dataframe() + + frame.loc[1, "site_id"] = "SITE999" + + metadata = make_metadata() + + with pytest.raises( + ValueError, + match="Sites incohérents", + ): + validate_source( + frame, + metadata, + ) + + +def test_normalize_timestamps_adds_timezone(): + frame = make_dataframe() + + normalized = normalize_timestamps( + frame, + "UTC", + ) + + assert normalized["timestamp"].dt.tz is not None + + assert "_source_timestamp" in normalized.columns + + +def test_classify_quality_good(): + row = make_dataframe().iloc[0].to_dict() + + quality, reasons = classify_quality(row) + + assert quality == "good" + assert reasons == [] + + +def test_classify_quality_degraded_when_consumption_missing(): + row = make_dataframe().iloc[0].to_dict() + row["consumption_kwh"] = None + + quality, reasons = classify_quality(row) + + assert quality == "degraded" + + assert "missing:consumption_kwh" in reasons + + +def test_build_reading_batch_respects_database_contract(): + frame = normalize_timestamps( + make_dataframe(), + "UTC", + ) + + rows = build_reading_batch( + frame.iloc[:1], + dataset_id=3, + ) + + assert len(rows) == 1 + + row = rows[0] + + assert row["dataset_id"] == 3 + + # Important : + # contrainte ck_reading_dataset_source. + assert row["source"] == "csv" + assert SOURCE_NAME == "csv" + + # Important : + # contrainte ck_reading_imputation. + assert row["imputed_values"] is None + assert row["imputation_method"] is None + + assert row["data_quality"] == "good" + assert row["null_reasons"] == [] + + +def test_build_reading_batch_keeps_missing_values(): + frame = make_dataframe() + + frame.loc[0, "temperature_celsius"] = None + + frame = normalize_timestamps( + frame, + "UTC", + ) + + rows = build_reading_batch( + frame.iloc[:1], + dataset_id=3, + ) + + row = rows[0] + + assert row["temperature_celsius"] is None + + assert "missing:temperature_celsius" in row["null_reasons"] + + # RAW ingestion : aucune imputation. + assert row["imputed_values"] is None + assert row["imputation_method"] is None diff --git a/apps/backend/tests/repositories/test_audit_log.py b/apps/backend/tests/repositories/test_audit_log.py index beacc8e..9edbe5c 100644 --- a/apps/backend/tests/repositories/test_audit_log.py +++ b/apps/backend/tests/repositories/test_audit_log.py @@ -49,8 +49,10 @@ async def test_the_database_refuses_to_mutate_the_audit_log( ) -> None: await une_ligne(session) + requete = text(instruction) + with pytest.raises(DBAPIError, match="ajout seul"): - await session.execute(text(instruction)) + await session.execute(requete) await session.rollback() diff --git a/apps/backend/tests/repositories/test_password_reset_token.py b/apps/backend/tests/repositories/test_password_reset_token.py index fe99800..eebbd21 100644 --- a/apps/backend/tests/repositories/test_password_reset_token.py +++ b/apps/backend/tests/repositories/test_password_reset_token.py @@ -131,11 +131,14 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( user_agent=None, ) + empreinte = fingerprint_refresh(secret) + expiration = datetime.now(UTC) + DUREE + with pytest.raises(IntegrityError): await depot.create( user_id=compte, - token_hash=fingerprint_refresh(secret), - expires_at=datetime.now(UTC) + DUREE, + token_hash=empreinte, + expires_at=expiration, client_ip=None, user_agent=None, ) diff --git a/apps/backend/tests/repositories/test_refresh_token.py b/apps/backend/tests/repositories/test_refresh_token.py index 73d82b4..f1adad8 100644 --- a/apps/backend/tests/repositories/test_refresh_token.py +++ b/apps/backend/tests/repositories/test_refresh_token.py @@ -178,12 +178,16 @@ async def test_the_database_refuses_two_tokens_sharing_a_fingerprint( user_agent=None, ) + famille = uuid.uuid4() + empreinte = fingerprint_refresh(secret) + expiration = datetime.now(UTC) + DUREE + with pytest.raises(IntegrityError): await depot.create( user_id=compte, - family_id=uuid.uuid4(), - token_hash=fingerprint_refresh(secret), - expires_at=datetime.now(UTC) + DUREE, + family_id=famille, + token_hash=empreinte, + expires_at=expiration, client_ip=None, user_agent=None, ) diff --git a/apps/backend/tests/repositories/test_user.py b/apps/backend/tests/repositories/test_user.py index 0701a2d..e52284f 100644 --- a/apps/backend/tests/repositories/test_user.py +++ b/apps/backend/tests/repositories/test_user.py @@ -31,14 +31,12 @@ async def test_the_database_refuses_an_email_written_in_upper_case( ) -> None: saisie = adresse().upper() + requete = text( + "insert into app_user (email, password_hash, role) values (:e, '$argon2id$x', 'lecteur')" + ) + with pytest.raises(IntegrityError): - await session.execute( - text( - "insert into app_user (email, password_hash, role) " - "values (:e, '$argon2id$x', 'lecteur')" - ), - {"e": saisie}, - ) + await session.execute(requete, {"e": saisie}) await session.rollback() diff --git a/apps/backend/tests/services/test_reading.py b/apps/backend/tests/services/test_reading.py index a3f0826..5718281 100644 --- a/apps/backend/tests/services/test_reading.py +++ b/apps/backend/tests/services/test_reading.py @@ -116,13 +116,11 @@ async def test_list_history_normalizes_naive_datetimes_to_utc() -> None: async def test_list_history_raises_when_start_is_after_end() -> None: service = ReadingService(readings=FakeRepository([])) + debut = datetime(2026, 9, 2, tzinfo=UTC) + fin = datetime(2026, 9, 1, tzinfo=UTC) + with pytest.raises(FenetreInverseeError): - await service.list_history( - start=datetime(2026, 9, 2, tzinfo=UTC), - end=datetime(2026, 9, 1, tzinfo=UTC), - limit=500, - offset=0, - ) + await service.list_history(start=debut, end=fin, limit=500, offset=0) async def test_list_history_raises_when_start_equals_end() -> None: diff --git a/apps/backend/tests/services/test_user.py b/apps/backend/tests/services/test_user.py index acb9463..0cd2d0c 100644 --- a/apps/backend/tests/services/test_user.py +++ b/apps/backend/tests/services/test_user.py @@ -235,5 +235,7 @@ async def test_every_operation_refuses_an_unknown_account(action: str) -> None: if action == "set_active": arguments["is_active"] = False + methode = getattr(attirail.service, action) + with pytest.raises(UserNotFoundError): - await getattr(attirail.service, action)(**arguments) + await methode(**arguments) diff --git a/apps/backend/tests/test_cli.py b/apps/backend/tests/test_cli.py index 2edf814..530efee 100644 --- a/apps/backend/tests/test_cli.py +++ b/apps/backend/tests/test_cli.py @@ -19,13 +19,17 @@ def test_build_parser_reads_the_create_admin_arguments() -> None: def test_build_parser_requires_a_subcommand() -> None: + parser = cli.build_parser() + with pytest.raises(SystemExit): - cli.build_parser().parse_args([]) + parser.parse_args([]) def test_build_parser_requires_an_email() -> None: + parser = cli.build_parser() + with pytest.raises(SystemExit): - cli.build_parser().parse_args(["create-admin"]) + parser.parse_args(["create-admin"]) def test_read_password_generates_a_long_secret_when_asked(