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>
413 lines
12 KiB
Python
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
|