Files
lifetrack/apps/api/app/tests/modules/test_health_ingest.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

413 lines
12 KiB
Python

"""JSON ingestion for the health domain: canonical shape, health-connect-webhook
bridge shape, idempotence and per-record error reporting."""
import datetime as dt
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.ingest.registry import INGEST_REGISTRY
from app.modules.health import (
ingest as health_ingest, # noqa: F401 — registers the handler
)
from app.modules.health.models import (
DailyActivity,
FoodEntry,
WaterEntry,
WeightEntry,
Workout,
)
URL = "/api/ingest/health"
def post(
client: TestClient, headers: dict[str, str], *records, source="android_bridge"
):
response = client.post(
URL, json={"source": source, "records": list(records)}, headers=headers
)
assert response.status_code == 200, response.text
return response.json()
# --- Registration & auth -------------------------------------------------------
def test_handler_is_registered_with_every_record_type() -> None:
handler = INGEST_REGISTRY["health"]
assert set(handler.record_types) == {
"weight",
"steps",
"distance",
"active_calories",
"total_calories",
"exercise_session",
"nutrition",
"hydration",
}
def test_ingest_requires_a_credential(client: TestClient) -> None:
response = client.post(
URL, json={"source": "x", "records": [{"type": "steps", "data": {}}]}
)
assert response.status_code == 401
def test_ingest_accepts_the_device_key(client: TestClient, device_key) -> None:
body = post(
client,
device_key.headers,
{
"type": "steps",
"external_id": "hc:2026-08-12",
"data": {"day": "2026-08-12", "steps": 9421},
},
)
assert body == {
"domain": "health",
"received": 1,
"inserted": 1,
"updated": 0,
"duplicates": 0,
"errors": [],
}
# --- Canonical shape (architecture §5.5) ---------------------------------------
def test_canonical_steps_record_fills_the_daily_row(
client: TestClient, db: Session, device_key
) -> None:
post(
client,
device_key.headers,
{
"type": "steps",
"external_id": "hc:2026-08-12",
"data": {
"day": "2026-08-12",
"steps": 9421,
"calories_kcal": 2350,
"distance_m": 6800,
},
},
)
row = db.scalar(select(DailyActivity))
assert row.date == dt.date(2026, 8, 12)
assert row.steps == 9421
assert float(row.active_kcal) == 2350.0 # bridge convention: active calories
assert row.distance_m == 6800
assert row.source == "health_connect" # android_bridge is a known alias
assert row.external_id == "hc:2026-08-12"
assert row.raw["day"] == "2026-08-12"
def test_reposting_the_same_day_is_idempotent_then_updates(
client: TestClient, db: Session, device_key
) -> None:
record = {
"type": "steps",
"external_id": "hc:2026-08-12",
"data": {"day": "2026-08-12", "steps": 9421},
}
assert post(client, device_key.headers, record)["inserted"] == 1
assert post(client, device_key.headers, record)["duplicates"] == 1
grown = {**record, "data": {**record["data"], "steps": 12500}}
assert post(client, device_key.headers, grown)["updated"] == 1
rows = db.scalars(select(DailyActivity)).all()
assert len(rows) == 1
assert rows[0].steps == 12500
def test_each_activity_type_targets_its_own_field(
client: TestClient, db: Session, device_key
) -> None:
post(
client,
device_key.headers,
{"type": "steps", "data": {"day": "2026-08-12", "steps": 100}},
{"type": "distance", "data": {"day": "2026-08-12", "value": 4200}},
{"type": "active_calories", "data": {"day": "2026-08-12", "value": 480}},
{"type": "total_calories", "data": {"day": "2026-08-12", "value": 2600}},
)
rows = db.scalars(select(DailyActivity)).all()
assert len(rows) == 1 # same (user, day, source)
row = rows[0]
assert (row.steps, row.distance_m) == (100, 4200)
assert float(row.active_kcal) == 480.0
assert float(row.total_kcal) == 2600.0
def test_canonical_weight_record(client: TestClient, db: Session, device_key) -> None:
post(
client,
device_key.headers,
{
"type": "weight",
"external_id": "hc:w:1755012345",
"data": {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 91.4},
},
)
row = db.scalar(select(WeightEntry))
assert float(row.weight_kg) == 91.4
assert row.measured_at == dt.datetime(2026, 8, 13, 6, 31, tzinfo=dt.UTC)
assert row.source == "health_connect"
# --- health-connect-webhook bridge shape ---------------------------------------
def test_bridge_weight_record_with_nested_value_and_metadata(
client: TestClient, db: Session, device_key
) -> None:
post(
client,
device_key.headers,
{
"type": "weight",
"data": {
"time": "2026-08-13T06:31:00Z",
"value": {"weight": 91.4, "unit": "kg"},
"metadata": {
"id": "hc-uuid-1",
"data_origin": "com.sec.android.app.shealth",
},
},
},
source="health-connect-webhook",
)
row = db.scalar(select(WeightEntry))
assert float(row.weight_kg) == 91.4
assert row.external_id == "hc-uuid-1"
assert row.source == "health_connect"
assert row.raw["metadata"]["id"] == "hc-uuid-1"
# Re-pushing the same record (48 h sliding window) must not duplicate it.
body = post(
client,
device_key.headers,
{
"type": "weight",
"data": {
"time": "2026-08-13T06:31:00Z",
"value": {"weight": 91.4},
"metadata": {"id": "hc-uuid-1"},
},
},
source="health-connect-webhook",
)
assert body["duplicates"] == 1
def test_bridge_steps_record_uses_count_and_start_time(
client: TestClient, db: Session, device_key
) -> None:
post(
client,
device_key.headers,
{
"type": "steps",
"data": {
"start_time": "2026-08-12T05:00:00Z",
"end_time": "2026-08-12T20:00:00Z",
"count": 9421,
"metadata": {"id": "hc-steps-1"},
},
},
source="health-connect-webhook",
)
row = db.scalar(select(DailyActivity))
assert row.date == dt.date(2026, 8, 12) # local day in Europe/Paris
assert row.steps == 9421
def test_bridge_exercise_session_becomes_a_workout(
client: TestClient, db: Session, device_key
) -> None:
post(
client,
device_key.headers,
{
"type": "exercise_session",
"data": {
"start_time": "2026-08-12T16:00:00Z",
"end_time": "2026-08-12T17:00:00Z",
"exercise_type": "RUNNING_TREADMILL",
"energy_kcal": 620,
"distance_m": 9500,
"metadata": {"id": "hc-ex-1"},
},
},
source="health-connect-webhook",
)
row = db.scalar(select(Workout))
assert row.sport_type.value == "treadmill_run"
assert row.duration_s == 3600
assert float(row.kcal) == 620.0
assert row.distance_m == 9500
assert row.external_id == "hc-ex-1"
assert row.is_hidden is False
def test_overlapping_sessions_from_two_sources_hide_the_weaker_one(
client: TestClient, db: Session, device_key
) -> None:
session = {
"type": "exercise_session",
"data": {
"start_time": "2026-08-12T16:00:00Z",
"end_time": "2026-08-12T17:00:00Z",
"exercise_type": "RUNNING_TREADMILL",
"metadata": {"id": "fs-1"},
},
}
post(client, device_key.headers, session, source="fitshow")
post(
client,
device_key.headers,
{**session, "data": {**session["data"], "metadata": {"id": "hc-1"}}},
source="health-connect-webhook",
)
rows = {row.source: row for row in db.scalars(select(Workout)).all()}
assert len(rows) == 2
assert rows["health_connect"].is_hidden is False # higher priority source
assert rows["fitshow"].is_hidden is True
def test_bridge_nutrition_record_maps_meal_and_grams(
client: TestClient, db: Session, device_key
) -> None:
post(
client,
device_key.headers,
{
"type": "nutrition",
"data": {
"start_time": "2026-08-13T11:45:00Z",
"end_time": "2026-08-13T12:15:00Z",
"meal_type": 2,
"name": "Salade poulet",
"energy_kcal": 620.0,
"protein_g": 42.1,
"total_carbohydrate_g": 51.0,
"total_fat_g": 24.3,
"dietary_fiber_g": 7.0,
"sodium_g": 1.1,
"metadata": {"id": "hc-nut-1"},
},
},
source="health-connect-webhook",
)
row = db.scalar(select(FoodEntry))
assert row.meal.value == "lunch"
assert row.name == "Salade poulet"
assert float(row.kcal) == 620.0
assert float(row.carbs_g) == 51.0
assert float(row.sodium_mg) == 1100.0 # Health Connect sends grams
def test_nutrition_meal_falls_back_on_the_local_hour(
client: TestClient, db: Session, device_key
) -> None:
post(
client,
device_key.headers,
{
"type": "nutrition",
"data": {
"start_time": "2026-08-13T06:00:00Z", # 08:00 Paris
"name": "Café",
"energy_kcal": 5,
"meal_type": 0,
},
},
)
assert db.scalar(select(FoodEntry)).meal.value == "breakfast"
def test_bridge_hydration_record(client: TestClient, db: Session, device_key) -> None:
post(
client,
device_key.headers,
{
"type": "hydration",
"data": {
"start_time": "2026-08-13T09:00:00Z",
"volume_liters": 0.5,
"metadata": {"id": "hc-hyd-1"},
},
},
source="health-connect-webhook",
)
row = db.scalar(select(WaterEntry))
assert row.volume_ml == 500
assert row.external_id == "hc-hyd-1"
def test_records_without_external_id_dedupe_on_content(
client: TestClient, db: Session, device_key
) -> None:
record = {
"type": "hydration",
"data": {"start_time": "2026-08-13T09:00:00Z", "volume_ml": 250},
}
assert post(client, device_key.headers, record)["inserted"] == 1
assert post(client, device_key.headers, record)["duplicates"] == 1
assert len(db.scalars(select(WaterEntry)).all()) == 1
# --- Error handling ------------------------------------------------------------
def test_unsupported_type_is_reported_per_record(
client: TestClient, device_key
) -> None:
body = post(
client,
device_key.headers,
{"type": "sleep", "data": {"day": "2026-08-12"}},
{"type": "steps", "data": {"day": "2026-08-12", "steps": 10}},
)
assert body["inserted"] == 1
assert body["errors"][0]["index"] == 0
assert body["errors"][0]["type"] == "sleep"
def test_invalid_records_do_not_fail_the_batch(
client: TestClient, db: Session, device_key
) -> None:
body = post(
client,
device_key.headers,
{"type": "weight", "data": {"measured_at": "2026-08-13T06:31:00Z"}},
{
"type": "weight",
"data": {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 900},
},
{"type": "steps", "data": {"steps": 10}},
{
"type": "weight",
"data": {"measured_at": "2026-08-13T07:00:00Z", "weight_kg": 91.4},
},
)
assert body["inserted"] == 1
assert len(body["errors"]) == 3
messages = " ".join(error["message"] for error in body["errors"])
assert "requis" in messages
assert "bornes" in messages
assert len(db.scalars(select(WeightEntry)).all()) == 1
def test_unknown_domain_is_a_404(client: TestClient, device_key) -> None:
response = client.post(
"/api/ingest/sleep",
json={"source": "x", "records": [{"type": "steps", "data": {}}]},
headers=device_key.headers,
)
assert response.status_code == 404