Files
lifetrack/apps/api/app/tests/modules/test_health_importers.py
T
MeeJayandClaude Opus 5 93f0689c1e Initial import: LifeTrack v1 (santé, vape, finances)
Tracker de vie auto-hébergé : suivi poids/calories/sport avec planning de
pesées, sevrage tabac (vape) avec modèle de coût DIY et économies, et
finances personnelles avec import de relevés bancaires.

Architecture : FastAPI + SQLAlchemy 2.0 + PostgreSQL 16, React 18 + TS +
Vite + Tailwind + ECharts, déploiement Docker Compose. Modules
auto-découverts des deux côtés (pkgutil / import.meta.glob) et framework
de connecteurs à deux voies (importeurs de fichiers + ingestion JSON)
pour brancher de nouvelles sources sans toucher au noyau.

Validé : 292 tests pytest, tsc + vite build, contrat API/web vérifié
contre le schéma OpenAPI, et déploiement Docker réel sur PostgreSQL 16
(28 tables, SPA servie par nginx, wizard de premier démarrage).

Documentation : README.md, docs/GUIDE.md, CONVENTIONS.md, et les
documents de conception et de recherche dans docs/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 10:48:57 +02:00

237 lines
9.1 KiB
Python

"""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() == []