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

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"]