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