"""File importers of the health module: sniffing, parsing tolerance, and idempotent deduplication (re-importing the same file changes nothing).""" import datetime as dt from pathlib import Path import pytest from sqlalchemy import select from sqlalchemy.orm import Session from app.core.importing.registry import IMPORTER_REGISTRY, detect_importer from app.modules.auth.models import User from app.modules.health import importers as health_importers from app.modules.health.models import DailyActivity, FoodEntry, WeightEntry from app.modules.imports import service as imports_service from app.modules.imports.models import ImportRun FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "health" def fixture_bytes(name: str) -> bytes: return (FIXTURES / name).read_bytes() def run(db: Session, user: User, importer_id: str, name: str) -> ImportRun: return imports_service.run_import(db, user, importer_id, name, fixture_bytes(name)) # --- Registration & sniffing --------------------------------------------------- def test_the_three_importers_are_registered() -> None: for importer_id in ("foodvisor_csv", "health_sync_csv", "weight_generic_csv"): assert importer_id in IMPORTER_REGISTRY assert IMPORTER_REGISTRY[importer_id].domain == "health" assert IMPORTER_REGISTRY[importer_id].accepted_extensions == (".csv",) # French label, shown as-is in the source picker assert IMPORTER_REGISTRY[importer_id].label @pytest.mark.parametrize( ("filename", "expected"), [ ("foodvisor_export.csv", "foodvisor_csv"), ("health_sync.csv", "health_sync_csv"), ("weight_history.csv", "weight_generic_csv"), ], ) def test_sniffing_is_unambiguous(filename: str, expected: str) -> None: detected = detect_importer(filename, fixture_bytes(filename)[:4096]) assert detected is not None, f"{filename} not detected" assert detected.id == expected def test_sniff_never_raises_on_garbage() -> None: for cls in ( health_importers.FoodvisorCsvImporter, health_importers.HealthSyncCsvImporter, health_importers.WeightGenericCsvImporter, ): assert cls.sniff("x.pdf", b"%PDF-1.7") is False assert cls.sniff("x.csv", b"\xff\xfe\x00binary") is False assert cls.sniff("x.csv", b"") is False # --- Parsing helpers ----------------------------------------------------------- def test_decoding_falls_back_to_cp1252() -> None: assert health_importers.decode_bytes("Protéines".encode()) == "Protéines" assert health_importers.decode_bytes("Protéines".encode("cp1252")) == "Protéines" def test_number_parsing_is_tolerant() -> None: to_decimal = health_importers.to_decimal assert float(to_decimal("1 234,5")) == 1234.5 assert float(to_decimal("< 0,5")) == 0.5 assert float(to_decimal("traces")) == 0.0 assert to_decimal("-") is None assert to_decimal("") is None assert float(to_decimal("228.0")) == 228.0 def test_naive_timestamps_are_read_as_paris_time() -> None: parsed = health_importers.parse_datetime("2026-08-10 08:15") assert parsed == dt.datetime(2026, 8, 10, 6, 15, tzinfo=dt.UTC) # CEST = UTC+2 assert health_importers.parse_datetime("10/08/2026") is not None assert health_importers.parse_datetime("2026-08-10T08:15:00Z") == dt.datetime( 2026, 8, 10, 8, 15, tzinfo=dt.UTC ) assert health_importers.parse_datetime("n'importe quoi") is None def test_meal_mapping_fr_en() -> None: assert health_importers.map_meal("Petit-déjeuner").value == "breakfast" assert health_importers.map_meal("Déjeuner").value == "lunch" assert health_importers.map_meal("Dîner").value == "dinner" assert health_importers.map_meal("Collation").value == "snack" assert health_importers.map_meal("Breakfast").value == "breakfast" assert health_importers.map_meal("inconnu").value == "snack" # --- Foodvisor ----------------------------------------------------------------- def test_foodvisor_import_maps_meals_and_macros(db: Session, user: User) -> None: result = run(db, user, "foodvisor_csv", "foodvisor_export.csv") assert result.status == "completed" assert (result.rows_total, result.rows_inserted, result.rows_errors) == (4, 4, 0) entries = db.scalars(select(FoodEntry).order_by(FoodEntry.eaten_at.asc())).all() assert [entry.meal.value for entry in entries] == [ "breakfast", "lunch", "dinner", "snack", ] first = entries[0] assert first.name == "Flocons d'avoine" assert first.brand == "Quaker" assert float(first.kcal) == 228.0 assert float(first.protein_g) == 8.1 assert float(first.quantity) == 60.0 assert first.source == "foodvisor" assert first.external_id is not None assert first.import_run_id == result.id assert first.raw["aliment"] == "Flocons d'avoine" # 2026-08-10 08:15 Paris -> 06:15 UTC assert first.eaten_at == dt.datetime(2026, 8, 10, 6, 15, tzinfo=dt.UTC) assert entries[1].brand is None def test_foodvisor_reimport_is_fully_duplicate(db: Session, user: User) -> None: run(db, user, "foodvisor_csv", "foodvisor_export.csv") second = run(db, user, "foodvisor_csv", "foodvisor_export.csv") assert second.rows_total == 4 assert second.rows_duplicates == 4 assert second.rows_inserted == 0 assert db.scalar(select(FoodEntry).where(FoodEntry.user_id == user.id)) is not None assert len(db.scalars(select(FoodEntry)).all()) == 4 def test_foodvisor_rejects_incomplete_rows(db: Session, user: User) -> None: data = b"Date;Repas;Aliment;Calories (kcal)\n;Dejeuner;;\n" result = imports_service.run_import(db, user, "foodvisor_csv", "x.csv", data) assert result.rows_errors == 1 assert "requis" in result.error_details[0]["message"] # --- Health Sync --------------------------------------------------------------- def test_health_sync_import_splits_weights_and_activity( db: Session, user: User ) -> None: result = run(db, user, "health_sync_csv", "health_sync.csv") assert result.status == "completed" # 3 rows -> 3 activity records + 2 weights (one row has no weight) assert result.rows_total == 5 assert result.rows_inserted == 5 activities = db.scalars( select(DailyActivity).order_by(DailyActivity.date.asc()) ).all() assert [row.date for row in activities] == [ dt.date(2026, 8, 10), dt.date(2026, 8, 11), dt.date(2026, 8, 12), ] assert activities[0].steps == 9421 assert activities[0].distance_m == 6800 # 6,80 km -> metres assert float(activities[0].active_kcal) == 520.0 assert activities[0].source == "csv_import" assert activities[0].import_run_id == result.id weights = db.scalars( select(WeightEntry).order_by(WeightEntry.measured_at.asc()) ).all() assert [float(row.weight_kg) for row in weights] == [92.4, 92.1] def test_health_sync_reimport_is_fully_duplicate(db: Session, user: User) -> None: run(db, user, "health_sync_csv", "health_sync.csv") second = run(db, user, "health_sync_csv", "health_sync.csv") assert second.rows_total == 5 assert second.rows_duplicates == 5 assert second.rows_inserted == 0 assert second.rows_updated == 0 def test_health_sync_updated_values_replace_the_day(db: Session, user: User) -> None: run(db, user, "health_sync_csv", "health_sync.csv") later = b"Date;Pas;Distance (km);Calories actives\n2026-08-10;11000;7,50;600\n" result = imports_service.run_import(db, user, "health_sync_csv", "hs.csv", later) assert result.rows_updated == 1 assert result.rows_inserted == 0 row = db.scalar( select(DailyActivity).where(DailyActivity.date == dt.date(2026, 8, 10)) ) assert row.steps == 11000 assert row.distance_m == 7500 # --- Generic weight history ---------------------------------------------------- def test_weight_generic_import_and_reimport(db: Session, user: User) -> None: result = run(db, user, "weight_generic_csv", "weight_history.csv") assert (result.rows_total, result.rows_inserted) == (3, 3) weights = db.scalars( select(WeightEntry).order_by(WeightEntry.measured_at.asc()) ).all() assert [float(row.weight_kg) for row in weights] == [95.2, 94.1, 93.4] assert weights[0].source == "csv_import" assert weights[0].measured_at == dt.datetime(2026, 6, 30, 22, 0, tzinfo=dt.UTC) second = run(db, user, "weight_generic_csv", "weight_history.csv") assert second.rows_duplicates == 3 assert len(db.scalars(select(WeightEntry)).all()) == 3 def test_weight_generic_rejects_out_of_range(db: Session, user: User) -> None: data = b"date;poids\n2026-07-01;950,0\n" result = imports_service.run_import(db, user, "weight_generic_csv", "w.csv", data) assert result.rows_errors == 1 assert "bornes" in result.error_details[0]["message"] # --- Central rollback ---------------------------------------------------------- def test_deleting_an_import_run_removes_its_rows(db: Session, user: User) -> None: result = run(db, user, "foodvisor_csv", "foodvisor_export.csv") assert len(db.scalars(select(FoodEntry)).all()) == 4 imports_service.rollback_run(db, user.id, result.id) assert db.scalars(select(FoodEntry)).all() == []