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>
1026 lines
32 KiB
Python
1026 lines
32 KiB
Python
"""Contract tests for the health router (/api/health)."""
|
|
|
|
import datetime as dt
|
|
from collections.abc import Callable
|
|
from decimal import Decimal
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.modules.auth.models import User
|
|
from app.modules.health import calculations as calc
|
|
from app.modules.health.models import DailyActivity
|
|
|
|
PARIS = ZoneInfo("Europe/Paris")
|
|
BASE = "/api/health"
|
|
|
|
|
|
def today_paris() -> dt.date:
|
|
return dt.datetime.now(dt.UTC).astimezone(PARIS).date()
|
|
|
|
|
|
def at(day: dt.date, hour: int, minute: int = 0) -> str:
|
|
"""ISO-8601 UTC instant for a Paris wall-clock time on `day`."""
|
|
local = dt.datetime(day.year, day.month, day.day, hour, minute, tzinfo=PARIS)
|
|
return local.astimezone(dt.UTC).isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
PROFILE = {
|
|
"height_cm": 180.0,
|
|
"sex": "male",
|
|
"birthdate": "1990-08-13",
|
|
"activity_level": "moderate",
|
|
"timezone": "Europe/Paris",
|
|
"water_goal_ml": 2000,
|
|
}
|
|
|
|
|
|
def setup_profile(client: TestClient, headers: dict[str, str]) -> dict:
|
|
response = client.put(f"{BASE}/profile", json=PROFILE, headers=headers)
|
|
assert response.status_code == 200, response.text
|
|
return response.json()
|
|
|
|
|
|
# --- Auth & profile -----------------------------------------------------------
|
|
|
|
|
|
def test_endpoints_require_authentication(client: TestClient) -> None:
|
|
assert client.get(f"{BASE}/profile").status_code == 401
|
|
assert client.get(f"{BASE}/weights").status_code == 401
|
|
assert client.get(f"{BASE}/today").status_code == 401
|
|
|
|
|
|
def test_profile_missing_returns_french_404(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
response = client.get(f"{BASE}/profile", headers=auth_headers)
|
|
assert response.status_code == 404
|
|
error = response.json()["error"]
|
|
assert error["code"] == "not_found"
|
|
assert "Profil santé" in error["message"]
|
|
|
|
|
|
def test_profile_put_then_get_computes_age_bmr_tdee(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
body = setup_profile(client, auth_headers)
|
|
assert body["height_cm"] == 180.0
|
|
assert body["sex"] == "male"
|
|
# No weigh-in yet -> no BMR
|
|
assert body["bmr_kcal"] is None
|
|
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(today_paris(), 7), "weight_kg": 80.0},
|
|
headers=auth_headers,
|
|
)
|
|
body = client.get(f"{BASE}/profile", headers=auth_headers).json()
|
|
age = calc.age_on(today_paris(), dt.date(1990, 8, 13))
|
|
assert body["age"] == age
|
|
expected_bmr = calc.bmr_mifflin(80.0, 180.0, age, "male")
|
|
assert body["bmr_kcal"] == round(expected_bmr, 1)
|
|
assert body["tdee_estimated_kcal"] == round(expected_bmr * 1.55, 1)
|
|
assert body["bmi"] == 24.7
|
|
|
|
|
|
def test_profile_rejects_unknown_timezone(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
payload = {**PROFILE, "timezone": "Mars/Olympus"}
|
|
response = client.put(f"{BASE}/profile", json=payload, headers=auth_headers)
|
|
assert response.status_code == 422
|
|
assert "Fuseau horaire" in response.json()["error"]["message"]
|
|
|
|
|
|
# --- Weights ------------------------------------------------------------------
|
|
|
|
|
|
def test_weight_crud_and_pagination(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
setup_profile(client, auth_headers)
|
|
today = today_paris()
|
|
for offset, weight in enumerate([92.4, 92.0, 91.6]):
|
|
response = client.post(
|
|
f"{BASE}/weights",
|
|
json={
|
|
"measured_at": at(today - dt.timedelta(days=offset), 7),
|
|
"weight_kg": weight,
|
|
"note": "après le sport",
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
|
|
listing = client.get(
|
|
f"{BASE}/weights", params={"page_size": 2}, headers=auth_headers
|
|
).json()
|
|
assert listing["total"] == 3
|
|
assert len(listing["items"]) == 2
|
|
assert listing["items"][0]["weight_kg"] == 92.4 # -measured_at by default
|
|
assert listing["items"][0]["source"] == "manual"
|
|
|
|
entry_id = listing["items"][0]["id"]
|
|
patched = client.patch(
|
|
f"{BASE}/weights/{entry_id}", json={"weight_kg": 92.5}, headers=auth_headers
|
|
)
|
|
assert patched.status_code == 200
|
|
assert patched.json()["weight_kg"] == 92.5
|
|
|
|
assert (
|
|
client.delete(f"{BASE}/weights/{entry_id}", headers=auth_headers).status_code
|
|
== 204
|
|
)
|
|
assert client.get(f"{BASE}/weights", headers=auth_headers).json()["total"] == 2
|
|
assert (
|
|
client.delete(f"{BASE}/weights/{entry_id}", headers=auth_headers).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_weight_duplicate_timestamp_is_a_conflict(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
payload = {"measured_at": at(today_paris(), 7), "weight_kg": 92.4}
|
|
assert (
|
|
client.post(f"{BASE}/weights", json=payload, headers=auth_headers).status_code
|
|
== 201
|
|
)
|
|
conflict = client.post(f"{BASE}/weights", json=payload, headers=auth_headers)
|
|
assert conflict.status_code == 409
|
|
assert "pesée" in conflict.json()["error"]["message"].lower()
|
|
|
|
|
|
def test_weight_out_of_range_is_rejected(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
response = client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(today_paris(), 7), "weight_kg": 5},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 422
|
|
|
|
|
|
def test_weight_stats_series_contract(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
setup_profile(client, auth_headers)
|
|
today = today_paris()
|
|
for offset in range(6):
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={
|
|
"measured_at": at(today - dt.timedelta(days=offset * 3), 7),
|
|
"weight_kg": 92.0 - 0.3 * (5 - offset),
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
client.post(
|
|
f"{BASE}/goals",
|
|
json={"mode": "weekly_rate", "target_weight_kg": 85.0, "weekly_rate_kg": 0.5},
|
|
headers=auth_headers,
|
|
)
|
|
body = client.get(f"{BASE}/weights/stats", headers=auth_headers).json()
|
|
assert set(body) >= {"from", "to", "unit", "series", "meta"}
|
|
assert body["unit"] == "kg"
|
|
names = {series["name"] for series in body["series"]}
|
|
assert {"weight_raw", "weight_trend", "plan_line"} <= names
|
|
raw = next(s for s in body["series"] if s["name"] == "weight_raw")
|
|
assert raw["type"] == "scatter"
|
|
assert all(len(point) == 2 for point in raw["points"])
|
|
assert body["meta"]["trend_now_kg"] is not None
|
|
assert body["meta"]["projection"]["status"] in {"ok", "reached", "not_converging"}
|
|
assert body["meta"]["target_weight_kg"] == 85.0
|
|
|
|
|
|
# --- Measurements -------------------------------------------------------------
|
|
|
|
|
|
def test_measurements_crud_and_navy_series(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
setup_profile(client, auth_headers)
|
|
response = client.post(
|
|
f"{BASE}/measurements",
|
|
json={
|
|
"measured_at": at(today_paris(), 8),
|
|
"waist_cm": 90.0,
|
|
"neck_cm": 38.0,
|
|
"chest_cm": 102.0,
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
assert response.json()["waist_cm"] == 90.0
|
|
|
|
stats = client.get(f"{BASE}/measurements/stats", headers=auth_headers).json()
|
|
names = {series["name"] for series in stats["series"]}
|
|
assert {"waist_cm", "neck_cm", "chest_cm", "body_fat_navy_pct"} <= names
|
|
assert stats["unit"] == "cm"
|
|
|
|
|
|
# --- Activity -----------------------------------------------------------------
|
|
|
|
|
|
def test_activity_merge_priority_per_field(
|
|
client: TestClient,
|
|
db: Session,
|
|
user: User,
|
|
auth_headers: dict[str, str],
|
|
) -> None:
|
|
day = today_paris()
|
|
db.add_all(
|
|
[
|
|
DailyActivity(
|
|
user_id=user.id,
|
|
date=day,
|
|
source="csv_import",
|
|
steps=5000,
|
|
distance_m=3000,
|
|
active_minutes=42,
|
|
),
|
|
DailyActivity(
|
|
user_id=user.id,
|
|
date=day,
|
|
source="health_connect",
|
|
steps=9421,
|
|
active_kcal=Decimal("520.0"),
|
|
),
|
|
]
|
|
)
|
|
db.commit()
|
|
assert (
|
|
client.post(
|
|
f"{BASE}/activity",
|
|
json={"date": day.isoformat(), "steps": 10000},
|
|
headers=auth_headers,
|
|
).status_code
|
|
== 201
|
|
)
|
|
|
|
merged = client.get(
|
|
f"{BASE}/activity",
|
|
params={"from": day.isoformat(), "to": day.isoformat()},
|
|
headers=auth_headers,
|
|
).json()
|
|
assert len(merged) == 1
|
|
row = merged[0]
|
|
assert row["steps"] == 10000
|
|
assert row["active_kcal"] == 520.0
|
|
assert row["distance_m"] == 3000
|
|
assert row["active_minutes"] == 42
|
|
assert row["field_sources"] == {
|
|
"steps": "manual",
|
|
"active_kcal": "health_connect",
|
|
"distance_m": "csv_import",
|
|
"active_minutes": "csv_import",
|
|
}
|
|
|
|
raw = client.get(
|
|
f"{BASE}/activity",
|
|
params={"from": day.isoformat(), "to": day.isoformat(), "raw": True},
|
|
headers=auth_headers,
|
|
).json()
|
|
assert len(raw) == 3
|
|
assert {item["source"] for item in raw} == {
|
|
"manual",
|
|
"health_connect",
|
|
"csv_import",
|
|
}
|
|
|
|
deleted = client.delete(f"{BASE}/activity/{raw[0]['id']}", headers=auth_headers)
|
|
assert deleted.status_code == 204
|
|
|
|
|
|
def test_activity_manual_upsert_replaces_the_same_day(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
day = today_paris().isoformat()
|
|
first = client.post(
|
|
f"{BASE}/activity", json={"date": day, "steps": 1000}, headers=auth_headers
|
|
).json()
|
|
second = client.post(
|
|
f"{BASE}/activity", json={"date": day, "steps": 2000}, headers=auth_headers
|
|
).json()
|
|
assert first["id"] == second["id"]
|
|
assert second["steps"] == 2000
|
|
|
|
|
|
def test_activity_stats_has_moving_averages(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
day = today_paris()
|
|
client.post(
|
|
f"{BASE}/activity",
|
|
json={"date": day.isoformat(), "steps": 12000, "active_kcal": 500.0},
|
|
headers=auth_headers,
|
|
)
|
|
body = client.get(f"{BASE}/activity/stats", headers=auth_headers).json()
|
|
names = {series["name"] for series in body["series"]}
|
|
assert {"steps", "steps_ma7", "active_kcal", "distance_m"} <= names
|
|
assert body["meta"]["avg_steps"] == 12000.0
|
|
assert body["meta"]["tracked_days"] == 1
|
|
|
|
|
|
# --- Workouts -----------------------------------------------------------------
|
|
|
|
|
|
def test_workout_crud_and_stats(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
day = today_paris()
|
|
response = client.post(
|
|
f"{BASE}/workouts",
|
|
json={
|
|
"started_at": at(day, 18),
|
|
"ended_at": at(day, 19),
|
|
"sport_type": "treadmill_run",
|
|
"kcal": 620.0,
|
|
"distance_m": 9500,
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
body = response.json()
|
|
assert body["duration_s"] == 3600
|
|
assert body["is_hidden"] is False
|
|
|
|
listing = client.get(f"{BASE}/workouts", headers=auth_headers).json()
|
|
assert listing["total"] == 1
|
|
|
|
stats = client.get(f"{BASE}/workouts/stats", headers=auth_headers).json()
|
|
assert stats["meta"]["sessions"] == 1
|
|
assert stats["meta"]["total_duration_min"] == 60.0
|
|
names = {series["name"] for series in stats["series"]}
|
|
assert {"sessions_count", "total_kcal", "by_sport_duration_min"} <= names
|
|
|
|
bad = client.post(
|
|
f"{BASE}/workouts",
|
|
json={
|
|
"started_at": at(day, 19),
|
|
"ended_at": at(day, 18),
|
|
"sport_type": "running",
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert bad.status_code == 422
|
|
assert "fin de séance" in bad.json()["error"]["message"]
|
|
|
|
|
|
def test_workout_overlap_hides_lower_priority_source(
|
|
client: TestClient,
|
|
db: Session,
|
|
user: User,
|
|
auth_headers: dict[str, str],
|
|
) -> None:
|
|
from app.modules.health.models import Workout
|
|
|
|
day = today_paris()
|
|
start = dt.datetime(day.year, day.month, day.day, 18, tzinfo=PARIS).astimezone(
|
|
dt.UTC
|
|
)
|
|
db.add(
|
|
Workout(
|
|
user_id=user.id,
|
|
source="fitshow",
|
|
started_at=start,
|
|
ended_at=start + dt.timedelta(minutes=60),
|
|
sport_type="treadmill_run",
|
|
)
|
|
)
|
|
db.commit()
|
|
# A manual session covering the same slot wins: fitshow gets hidden.
|
|
client.post(
|
|
f"{BASE}/workouts",
|
|
json={
|
|
"started_at": at(day, 18),
|
|
"ended_at": at(day, 19),
|
|
"sport_type": "treadmill_run",
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
visible = client.get(f"{BASE}/workouts", headers=auth_headers).json()
|
|
assert visible["total"] == 1
|
|
assert visible["items"][0]["source"] == "manual"
|
|
everything = client.get(
|
|
f"{BASE}/workouts", params={"include_hidden": True}, headers=auth_headers
|
|
).json()
|
|
assert everything["total"] == 2
|
|
|
|
|
|
# --- Goals --------------------------------------------------------------------
|
|
|
|
|
|
def test_goal_lifecycle_single_active(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
setup_profile(client, auth_headers)
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(today_paris(), 7), "weight_kg": 92.0},
|
|
headers=auth_headers,
|
|
)
|
|
payload = {
|
|
"mode": "weekly_rate",
|
|
"target_weight_kg": 85.0,
|
|
"weekly_rate_kg": 0.5,
|
|
}
|
|
first = client.post(f"{BASE}/goals", json=payload, headers=auth_headers)
|
|
assert first.status_code == 201, first.text
|
|
assert first.json()["start_weight_kg"] == 92.0
|
|
assert first.json()["status"] == "active"
|
|
|
|
conflict = client.post(f"{BASE}/goals", json=payload, headers=auth_headers)
|
|
assert conflict.status_code == 409
|
|
assert "objectif" in conflict.json()["error"]["message"].lower()
|
|
|
|
replaced = client.post(
|
|
f"{BASE}/goals",
|
|
json=payload,
|
|
params={"replace_active": True},
|
|
headers=auth_headers,
|
|
)
|
|
assert replaced.status_code == 201
|
|
history = client.get(f"{BASE}/goals", headers=auth_headers).json()
|
|
assert history["total"] == 2
|
|
assert {item["status"] for item in history["items"]} == {"active", "abandoned"}
|
|
|
|
reactivated = client.post(
|
|
f"{BASE}/goals/{first.json()['id']}/activate", headers=auth_headers
|
|
)
|
|
assert reactivated.status_code == 200
|
|
assert reactivated.json()["status"] == "active"
|
|
active = client.get(f"{BASE}/goals/active", headers=auth_headers).json()
|
|
assert active["goal"]["id"] == first.json()["id"]
|
|
assert active["budget"]["kcal"] > 0
|
|
assert active["budget"]["deficit_target_kcal"] == 550.0
|
|
assert active["progress_pct"] is not None
|
|
|
|
|
|
def test_goal_target_date_mode_requires_a_date(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
setup_profile(client, auth_headers)
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(today_paris(), 7), "weight_kg": 92.0},
|
|
headers=auth_headers,
|
|
)
|
|
response = client.post(
|
|
f"{BASE}/goals",
|
|
json={"mode": "target_date", "target_weight_kg": 85.0},
|
|
headers=auth_headers,
|
|
)
|
|
assert response.status_code == 422
|
|
assert "date cible" in response.json()["error"]["message"]
|
|
|
|
|
|
def test_active_goal_is_empty_without_goal(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
body = client.get(f"{BASE}/goals/active", headers=auth_headers).json()
|
|
assert body["goal"] is None
|
|
assert body["budget"] is None
|
|
|
|
|
|
# --- Nutrition ----------------------------------------------------------------
|
|
|
|
|
|
def add_food(
|
|
client: TestClient,
|
|
headers: dict[str, str],
|
|
day: dt.date,
|
|
hour: int,
|
|
meal: str,
|
|
name: str,
|
|
kcal: float,
|
|
**macros: float,
|
|
) -> dict:
|
|
payload = {
|
|
"eaten_at": at(day, hour),
|
|
"meal": meal,
|
|
"name": name,
|
|
"quantity": 100,
|
|
"unit": "g",
|
|
"kcal": kcal,
|
|
**macros,
|
|
}
|
|
response = client.post(f"{BASE}/nutrition/entries", json=payload, headers=headers)
|
|
assert response.status_code == 201, response.text
|
|
return response.json()
|
|
|
|
|
|
def test_food_entries_by_day_and_meal(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
day = today_paris()
|
|
add_food(
|
|
client,
|
|
auth_headers,
|
|
day,
|
|
8,
|
|
"breakfast",
|
|
"Flocons d'avoine",
|
|
228.0,
|
|
protein_g=8.1,
|
|
carbs_g=39.0,
|
|
fat_g=4.2,
|
|
)
|
|
add_food(
|
|
client,
|
|
auth_headers,
|
|
day,
|
|
13,
|
|
"lunch",
|
|
"Poulet rôti",
|
|
248.5,
|
|
protein_g=46.2,
|
|
carbs_g=0.0,
|
|
fat_g=5.4,
|
|
)
|
|
add_food(
|
|
client, auth_headers, day - dt.timedelta(days=1), 20, "dinner", "Pâtes", 350.0
|
|
)
|
|
|
|
same_day = client.get(
|
|
f"{BASE}/nutrition/entries",
|
|
params={"day": day.isoformat()},
|
|
headers=auth_headers,
|
|
).json()
|
|
assert same_day["total"] == 2
|
|
|
|
lunch = client.get(
|
|
f"{BASE}/nutrition/entries",
|
|
params={"day": day.isoformat(), "meal": "lunch"},
|
|
headers=auth_headers,
|
|
).json()
|
|
assert lunch["total"] == 1
|
|
assert lunch["items"][0]["name"] == "Poulet rôti"
|
|
|
|
searched = client.get(
|
|
f"{BASE}/nutrition/entries", params={"q": "poulet"}, headers=auth_headers
|
|
).json()
|
|
assert searched["total"] == 1
|
|
|
|
detail = client.get(
|
|
f"{BASE}/nutrition/days/{day.isoformat()}", headers=auth_headers
|
|
).json()
|
|
assert detail["totals"]["kcal"] == 476.5
|
|
meals = {meal["meal"]: meal for meal in detail["meals"]}
|
|
assert meals["breakfast"]["kcal"] == 228.0
|
|
assert len(meals["lunch"]["entries"]) == 1
|
|
assert meals["dinner"]["kcal"] == 0.0
|
|
|
|
|
|
def test_food_entry_update_and_delete(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
entry = add_food(client, auth_headers, today_paris(), 8, "breakfast", "Pain", 250.0)
|
|
patched = client.patch(
|
|
f"{BASE}/nutrition/entries/{entry['id']}",
|
|
json={"kcal": 300.0, "meal": "snack"},
|
|
headers=auth_headers,
|
|
).json()
|
|
assert patched["kcal"] == 300.0
|
|
assert patched["meal"] == "snack"
|
|
assert (
|
|
client.delete(
|
|
f"{BASE}/nutrition/entries/{entry['id']}", headers=auth_headers
|
|
).status_code
|
|
== 204
|
|
)
|
|
|
|
|
|
def test_favorites_crud_and_prorated_entry(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
favorite = client.post(
|
|
f"{BASE}/nutrition/favorites",
|
|
json={
|
|
"name": "Flocons d'avoine",
|
|
"brand": "Quaker",
|
|
"default_quantity": 100,
|
|
"unit": "g",
|
|
"kcal": 380.0,
|
|
"protein_g": 13.5,
|
|
"default_meal": "breakfast",
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert favorite.status_code == 201, favorite.text
|
|
favorite_id = favorite.json()["id"]
|
|
|
|
duplicate = client.post(
|
|
f"{BASE}/nutrition/favorites",
|
|
json={
|
|
"name": "Flocons d'avoine",
|
|
"brand": "Quaker",
|
|
"default_quantity": 100,
|
|
"kcal": 380.0,
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert duplicate.status_code == 409
|
|
|
|
entry = client.post(
|
|
f"{BASE}/nutrition/entries",
|
|
json={
|
|
"eaten_at": at(today_paris(), 8),
|
|
"meal": "breakfast",
|
|
"name": "ignoré",
|
|
"quantity": 60,
|
|
"kcal": 0,
|
|
"favorite_id": favorite_id,
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
assert entry.status_code == 201
|
|
assert entry.json()["kcal"] == 228.0 # 380 * 60 / 100
|
|
assert entry.json()["protein_g"] == 8.1
|
|
assert entry.json()["name"] == "Flocons d'avoine"
|
|
|
|
listing = client.get(f"{BASE}/nutrition/favorites", headers=auth_headers).json()
|
|
assert listing["items"][0]["use_count"] == 1
|
|
assert listing["items"][0]["last_used_at"] is not None
|
|
|
|
assert (
|
|
client.delete(
|
|
f"{BASE}/nutrition/favorites/{favorite_id}", headers=auth_headers
|
|
).status_code
|
|
== 204
|
|
)
|
|
|
|
|
|
def test_recent_foods_are_deduplicated(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
day = today_paris()
|
|
add_food(client, auth_headers, day, 8, "breakfast", "Pain complet", 250.0)
|
|
add_food(client, auth_headers, day, 9, "snack", "Pain complet", 130.0)
|
|
add_food(client, auth_headers, day, 13, "lunch", "Riz", 300.0)
|
|
recent = client.get(f"{BASE}/nutrition/recent", headers=auth_headers).json()
|
|
assert [item["name"] for item in recent] == ["Riz", "Pain complet"]
|
|
|
|
|
|
def test_water_crud_and_stats(client: TestClient, auth_headers: dict[str, str]) -> None:
|
|
setup_profile(client, auth_headers)
|
|
day = today_paris()
|
|
created = client.post(
|
|
f"{BASE}/nutrition/water",
|
|
json={"drunk_at": at(day, 10), "volume_ml": 500},
|
|
headers=auth_headers,
|
|
)
|
|
assert created.status_code == 201
|
|
client.post(
|
|
f"{BASE}/nutrition/water",
|
|
json={"drunk_at": at(day, 15), "volume_ml": 750},
|
|
headers=auth_headers,
|
|
)
|
|
stats = client.get(f"{BASE}/nutrition/water/stats", headers=auth_headers).json()
|
|
volume = next(s for s in stats["series"] if s["name"] == "volume_ml")
|
|
today_point = next(p for p in volume["points"] if p[0] == day.isoformat())
|
|
assert today_point[1] == 1250
|
|
assert stats["meta"]["goal_ml"] == 2000
|
|
assert (
|
|
client.delete(
|
|
f"{BASE}/nutrition/water/{created.json()['id']}", headers=auth_headers
|
|
).status_code
|
|
== 204
|
|
)
|
|
|
|
|
|
def test_nutrition_stats_contract(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
setup_profile(client, auth_headers)
|
|
day = today_paris()
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(day, 7), "weight_kg": 92.0},
|
|
headers=auth_headers,
|
|
)
|
|
add_food(
|
|
client,
|
|
auth_headers,
|
|
day,
|
|
8,
|
|
"breakfast",
|
|
"Avoine",
|
|
400.0,
|
|
protein_g=10.0,
|
|
carbs_g=60.0,
|
|
fat_g=8.0,
|
|
)
|
|
add_food(
|
|
client,
|
|
auth_headers,
|
|
day,
|
|
13,
|
|
"lunch",
|
|
"Poulet",
|
|
600.0,
|
|
protein_g=50.0,
|
|
carbs_g=20.0,
|
|
fat_g=25.0,
|
|
)
|
|
|
|
body = client.get(f"{BASE}/nutrition/stats", headers=auth_headers).json()
|
|
names = {series["name"] for series in body["series"]}
|
|
assert {
|
|
"kcal",
|
|
"budget_kcal",
|
|
"protein_g",
|
|
"protein_g_kcal",
|
|
"meal_breakfast_kcal",
|
|
"meal_lunch_kcal",
|
|
"top_foods_kcal",
|
|
} <= names
|
|
top = next(s for s in body["series"] if s["name"] == "top_foods_kcal")
|
|
assert top["points"][0] == ["Poulet", 600.0]
|
|
assert body["meta"]["tracked_days"] == 1
|
|
assert body["meta"]["avg_kcal"] == 1000.0
|
|
assert sum(body["meta"]["macro_split_pct"].values()) == 100.0
|
|
|
|
days = client.get(f"{BASE}/nutrition/days", headers=auth_headers).json()
|
|
current = next(row for row in days if row["date"] == day.isoformat())
|
|
assert current["kcal"] == 1000.0
|
|
assert current["budget_kcal"] is not None
|
|
assert current["vs_budget_kcal"] == round(
|
|
current["kcal"] - current["budget_kcal"], 1
|
|
)
|
|
|
|
|
|
# --- Energy balance & dashboard ------------------------------------------------
|
|
|
|
|
|
def test_energy_balance_contract(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
setup_profile(client, auth_headers)
|
|
day = today_paris()
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(day, 7), "weight_kg": 92.0},
|
|
headers=auth_headers,
|
|
)
|
|
client.post(
|
|
f"{BASE}/activity",
|
|
json={"date": day.isoformat(), "active_kcal": 500.0},
|
|
headers=auth_headers,
|
|
)
|
|
add_food(client, auth_headers, day, 13, "lunch", "Poulet", 1800.0)
|
|
|
|
body = client.get(f"{BASE}/energy-balance", headers=auth_headers).json()
|
|
names = {series["name"] for series in body["series"]}
|
|
assert {
|
|
"intake_kcal",
|
|
"tdee_kcal",
|
|
"balance_kcal",
|
|
"budget_kcal",
|
|
"cumulative_balance_kcal",
|
|
} <= names
|
|
assert body["unit"] == "kcal"
|
|
assert body["meta"]["profile_missing"] is False
|
|
assert body["meta"]["calibration_status"] == "insufficient_data"
|
|
assert body["meta"]["tdee_methods"][day.isoformat()] == "bmr_plus_active"
|
|
|
|
age = calc.age_on(day, dt.date(1990, 8, 13))
|
|
expected_tdee = calc.bmr_mifflin(92.0, 180.0, age, "male") + 500.0
|
|
tdee_series = next(s for s in body["series"] if s["name"] == "tdee_kcal")
|
|
point = next(p for p in tdee_series["points"] if p[0] == day.isoformat())
|
|
assert point[1] == round(expected_tdee, 1)
|
|
|
|
|
|
def test_energy_balance_without_profile_degrades(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
body = client.get(f"{BASE}/energy-balance", headers=auth_headers).json()
|
|
assert body["meta"]["profile_missing"] is True
|
|
tdee = next(s for s in body["series"] if s["name"] == "tdee_kcal")
|
|
assert all(point[1] is None for point in tdee["points"])
|
|
|
|
|
|
def test_dashboard_aggregates_the_home_screen(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
setup_profile(client, auth_headers)
|
|
day = today_paris()
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(day, 7), "weight_kg": 92.0},
|
|
headers=auth_headers,
|
|
)
|
|
client.post(
|
|
f"{BASE}/activity",
|
|
json={"date": day.isoformat(), "steps": 9421, "active_kcal": 520.0},
|
|
headers=auth_headers,
|
|
)
|
|
add_food(client, auth_headers, day, 13, "lunch", "Poulet", 700.0)
|
|
client.post(
|
|
f"{BASE}/nutrition/water",
|
|
json={"drunk_at": at(day, 10), "volume_ml": 500},
|
|
headers=auth_headers,
|
|
)
|
|
client.post(
|
|
f"{BASE}/workouts",
|
|
json={
|
|
"started_at": at(day, 18),
|
|
"ended_at": at(day, 19),
|
|
"sport_type": "running",
|
|
},
|
|
headers=auth_headers,
|
|
)
|
|
|
|
body = client.get(f"{BASE}/dashboard", headers=auth_headers).json()
|
|
assert body["date"] == day.isoformat()
|
|
assert body["weight_kg"] == 92.0
|
|
assert body["trend_weight_kg"] == 92.0
|
|
assert body["steps"] == 9421
|
|
assert body["intake_kcal"] == 700.0
|
|
assert body["budget_kcal"] is not None
|
|
assert body["remaining_kcal"] == round(body["budget_kcal"] - 700.0, 1)
|
|
assert body["water_ml"] == 500
|
|
assert body["water_goal_ml"] == 2000
|
|
assert body["workouts_this_week"] == 1
|
|
assert body["today"]["date"] == day.isoformat()
|
|
assert len(body["weight_series"]) == 1
|
|
|
|
|
|
# --- Planning ------------------------------------------------------------------
|
|
|
|
|
|
def test_schedules_default_to_disabled_then_upsert(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
defaults = client.get(f"{BASE}/schedules", headers=auth_headers).json()
|
|
assert [item["kind"] for item in defaults] == ["weigh_in", "workout", "food_log"]
|
|
assert all(item["enabled"] is False and item["weekdays"] == [] for item in defaults)
|
|
|
|
updated = client.put(
|
|
f"{BASE}/schedules/weigh_in",
|
|
json={"weekdays": [2, 0, 0, 4], "enabled": True},
|
|
headers=auth_headers,
|
|
)
|
|
assert updated.status_code == 200
|
|
assert updated.json() == {
|
|
"kind": "weigh_in",
|
|
"weekdays": [0, 2, 4],
|
|
"enabled": True,
|
|
}
|
|
|
|
again = client.put(
|
|
f"{BASE}/schedules/weigh_in",
|
|
json={"weekdays": [1], "enabled": False},
|
|
headers=auth_headers,
|
|
).json()
|
|
assert again["weekdays"] == [1]
|
|
assert again["enabled"] is False
|
|
|
|
invalid = client.put(
|
|
f"{BASE}/schedules/weigh_in",
|
|
json={"weekdays": [9], "enabled": True},
|
|
headers=auth_headers,
|
|
)
|
|
assert invalid.status_code == 422
|
|
assert "lundi" in invalid.json()["error"]["message"]
|
|
|
|
unknown = client.put(
|
|
f"{BASE}/schedules/sleep", json={"weekdays": [1]}, headers=auth_headers
|
|
)
|
|
assert unknown.status_code == 422
|
|
|
|
|
|
def test_today_derives_done_from_existing_tables(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
day = today_paris()
|
|
weekday = day.weekday()
|
|
for kind in ("weigh_in", "workout", "food_log"):
|
|
client.put(
|
|
f"{BASE}/schedules/{kind}",
|
|
json={"weekdays": [weekday], "enabled": True},
|
|
headers=auth_headers,
|
|
)
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(day, 7), "weight_kg": 91.6},
|
|
headers=auth_headers,
|
|
)
|
|
add_food(client, auth_headers, day, 13, "lunch", "Poulet", 620.0)
|
|
|
|
body = client.get(f"{BASE}/today", headers=auth_headers).json()
|
|
assert body["date"] == day.isoformat()
|
|
items = {item["kind"]: item for item in body["items"]}
|
|
assert items["weigh_in"] == {
|
|
"kind": "weigh_in",
|
|
"planned": True,
|
|
"done": True,
|
|
"value": 91.6,
|
|
}
|
|
assert items["food_log"]["done"] is True
|
|
assert items["food_log"]["value"] == 620.0
|
|
assert items["workout"]["planned"] is True
|
|
assert items["workout"]["done"] is False
|
|
assert items["workout"]["value"] is None
|
|
assert body["streaks"]["weigh_in"]["current"] == 1
|
|
|
|
|
|
def test_today_rest_day_when_nothing_is_planned(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
body = client.get(f"{BASE}/today", headers=auth_headers).json()
|
|
assert all(item["planned"] is False for item in body["items"])
|
|
assert body["streaks"]["weigh_in"]["current"] == 0
|
|
|
|
|
|
def test_adherence_heatmap_contract(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
day = today_paris()
|
|
weekday = day.weekday()
|
|
client.put(
|
|
f"{BASE}/schedules/weigh_in",
|
|
json={"weekdays": [weekday], "enabled": True},
|
|
headers=auth_headers,
|
|
)
|
|
client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(day, 7), "weight_kg": 91.6},
|
|
headers=auth_headers,
|
|
)
|
|
# Same weekday two weeks ago: planned but never weighed -> missed.
|
|
start = day - dt.timedelta(days=14)
|
|
body = client.get(
|
|
f"{BASE}/stats/adherence",
|
|
params={"from": start.isoformat(), "to": day.isoformat()},
|
|
headers=auth_headers,
|
|
).json()
|
|
assert body["from"] == start.isoformat()
|
|
assert body["to"] == day.isoformat()
|
|
kinds = {item["kind"]: item for item in body["kinds"]}
|
|
weigh_in = kinds["weigh_in"]
|
|
assert weigh_in["planned_days"] == 3
|
|
assert weigh_in["done_days"] == 1
|
|
assert weigh_in["missed_days"] == 2
|
|
assert weigh_in["adherence_pct"] == 33.3
|
|
assert len(weigh_in["days"]) == 15
|
|
statuses = {row["date"]: row["status"] for row in weigh_in["days"]}
|
|
assert statuses[day.isoformat()] == "done"
|
|
assert statuses[start.isoformat()] == "missed"
|
|
assert statuses[(day - dt.timedelta(days=1)).isoformat()] == "rest"
|
|
|
|
filtered = client.get(
|
|
f"{BASE}/stats/adherence",
|
|
params={"kind": "workout", "from": start.isoformat(), "to": day.isoformat()},
|
|
headers=auth_headers,
|
|
).json()
|
|
assert [item["kind"] for item in filtered["kinds"]] == ["workout"]
|
|
|
|
|
|
# --- Isolation ------------------------------------------------------------------
|
|
|
|
|
|
def test_data_is_scoped_to_the_owner(
|
|
client: TestClient,
|
|
db: Session,
|
|
auth_headers: dict[str, str],
|
|
make_auth_headers: Callable[[int], dict[str, str]],
|
|
) -> None:
|
|
from app.core.security import hash_password
|
|
from app.modules.auth.models import User as UserModel
|
|
|
|
other = UserModel(
|
|
email="other@lifetrack.local",
|
|
password_hash=hash_password("another-strong-password"),
|
|
display_name="Autre",
|
|
)
|
|
db.add(other)
|
|
db.commit()
|
|
created = client.post(
|
|
f"{BASE}/weights",
|
|
json={"measured_at": at(today_paris(), 7), "weight_kg": 92.0},
|
|
headers=auth_headers,
|
|
).json()
|
|
other_headers = make_auth_headers(other.id)
|
|
assert client.get(f"{BASE}/weights", headers=other_headers).json()["total"] == 0
|
|
assert (
|
|
client.patch(
|
|
f"{BASE}/weights/{created['id']}",
|
|
json={"weight_kg": 50.0},
|
|
headers=other_headers,
|
|
).status_code
|
|
== 404
|
|
)
|
|
|
|
|
|
def test_invalid_sort_field_is_rejected(
|
|
client: TestClient, auth_headers: dict[str, str]
|
|
) -> None:
|
|
response = client.get(
|
|
f"{BASE}/weights", params={"sort": "-secret"}, headers=auth_headers
|
|
)
|
|
assert response.status_code == 422
|
|
assert "tri" in response.json()["error"]["message"]
|