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>
237 lines
9.1 KiB
Python
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() == []
|