Files
lifetrack/apps/api/app/tests/modules/test_health_regressions.py
T
MeeJayandClaude Opus 5 fa3db7ff22 Fix 20+ defects found by real Docker/PostgreSQL deployment
Première exécution réelle de la stack (build des images, PostgreSQL 16,
parcours fonctionnels en HTTP) : 28 tables, 105 index, extension pg_trgm,
et 181 assertions rejouées après correction.

Santé : filtres enum invalides renvoyaient 500 au lieu d'une erreur
française ; plan_line s'arrêtait à la fin de la fenêtre du graphique au
lieu de la date d'atteinte de l'objectif ; deficit_target_kcal était
recalculé après le plancher calorique ; « dernière pesée » affichait deux
valeurs différentes selon l'endpoint ; objectif protéines absent.

Vape : durée de vie moyenne des résistances incluait la résistance en
cours ; archiver la recette active la laissait active ; coût théorique
inventé avant la date d'arrêt ; économies projetées dans le futur ;
€/ml arrondi à 2 décimales écrasait le modèle de coût DIY.

Finances : le sankey compensait crédits et débits non catégorisés ;
rows_total excluait les lignes filtrées, faussant l'arithmétique du
rapport d'import.

Socle : les erreurs HTTP du framework fuitaient en anglais dans
l'enveloppe française ; nginx renvoyait sa page 413 HTML au lieu du JSON
français ; fins de ligne normalisées en LF.

348 tests pytest (+7), ruff, tsc et vite build au vert.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 11:40:05 +02:00

394 lines
13 KiB
Python

"""Regression tests for defects found while walking the live health journey.
Each test below fails on the code as it was before the matching fix:
1. an unknown value on an enum-backed query filter (`sport_type`, `meal`,
`status`) reached SQLAlchemy and raised `LookupError` -> HTTP 500 with no
French error envelope;
2. `/health/weights/stats` drew `plan_line` from the goal start to the LAST DAY
OF THE REQUESTED WINDOW, so the plan reference line of the weight chart
moved with the chart period and was ~7x too steep for a `weekly_rate` goal
(datamodel-health-vape.md §5.6b: `days = delta / (weekly_rate_kg / 7)`);
3. `/health/goals/active` reported `deficit_target_kcal` as
`tdee_smoothed - budget`, i.e. the deficit left AFTER the calorie floor
clamped the budget, instead of the deficit AIMED AT by the goal
(§5.3 `BudgetResult.deficit_target = rate x 7700 / 7`);
4. `/health/energy-balance` never told the UI HOW the TDEE was obtained
(`tdee_mode`, `bmr_kcal`, `activity_factor`), so the « BMR x facteur »
sub-label of the TDEE KPI could never render;
5. `/health/nutrition/days/{day}` never sent a protein target, so the
« Protéines aujourd'hui » gauge showed `96 / — g` for ever
(ux-pages.md §15.3: default 1,6 g/kg of body weight);
6. `/health/weights/stats` reported `last_weight_kg` as the FIRST weigh-in of
the most recent day while `/health/dashboard` reported the LAST one — the
same French label « dernière pesée » showing two different numbers.
"""
import datetime as dt
from zoneinfo import ZoneInfo
from fastapi.testclient import TestClient
BASE = "/api/health"
PARIS = ZoneInfo("Europe/Paris")
PROFILE = {
"height_cm": 178.0,
"sex": "male",
"birthdate": "1990-05-12",
"activity_level": "moderate",
"timezone": "Europe/Paris",
}
def _today() -> dt.date:
return dt.datetime.now(dt.UTC).astimezone(PARIS).date()
def _at(day: dt.date, hour: int) -> str:
return (
dt.datetime(day.year, day.month, day.day, hour, tzinfo=PARIS)
.astimezone(dt.UTC)
.isoformat()
.replace("+00:00", "Z")
)
def _seed(client: TestClient, headers: dict[str, str]) -> None:
assert (
client.put(f"{BASE}/profile", json=PROFILE, headers=headers).status_code == 200
)
today = _today()
for offset, weight in ((6, 95.0), (4, 94.6), (2, 94.3), (0, 94.0)):
response = client.post(
f"{BASE}/weights",
json={
"measured_at": _at(today - dt.timedelta(days=offset), 7),
"weight_kg": weight,
},
headers=headers,
)
assert response.status_code == 201, response.text
# --- 1. Unknown enum filter values must be rejected, never crash --------------
def test_unknown_sport_type_filter_is_rejected(
client: TestClient, auth_headers: dict[str, str]
) -> None:
response = client.get(
f"{BASE}/workouts", params={"sport_type": "quidditch"}, headers=auth_headers
)
assert response.status_code == 422, response.text
assert response.json()["error"]["code"] == "validation_error"
def test_unknown_meal_filter_is_rejected(
client: TestClient, auth_headers: dict[str, str]
) -> None:
response = client.get(
f"{BASE}/nutrition/entries", params={"meal": "brunch"}, headers=auth_headers
)
assert response.status_code == 422, response.text
assert response.json()["error"]["code"] == "validation_error"
def test_unknown_goal_status_filter_is_rejected(
client: TestClient, auth_headers: dict[str, str]
) -> None:
response = client.get(
f"{BASE}/goals", params={"status": "zzz"}, headers=auth_headers
)
assert response.status_code == 422, response.text
assert response.json()["error"]["code"] == "validation_error"
def test_known_enum_filters_still_work(
client: TestClient, auth_headers: dict[str, str]
) -> None:
assert (
client.get(
f"{BASE}/workouts", params={"sport_type": "cycling"}, headers=auth_headers
).status_code
== 200
)
assert (
client.get(
f"{BASE}/nutrition/entries", params={"meal": "lunch"}, headers=auth_headers
).status_code
== 200
)
assert (
client.get(
f"{BASE}/goals", params={"status": "active"}, headers=auth_headers
).status_code
== 200
)
# --- 2. plan_line must follow the goal rate, not the chart window -------------
def test_plan_line_follows_the_weekly_rate_not_the_window(
client: TestClient, auth_headers: dict[str, str]
) -> None:
_seed(client, auth_headers)
today = _today()
start = today - dt.timedelta(days=6)
created = client.post(
f"{BASE}/goals",
json={
"mode": "weekly_rate",
"start_date": start.isoformat(),
"start_weight_kg": 95.0,
"target_weight_kg": 85.0,
"weekly_rate_kg": 0.5,
},
headers=auth_headers,
)
assert created.status_code == 201, created.text
# 10 kg at 0.5 kg/week = 20 weeks = 140 days after the goal start date.
expected_end = (start + dt.timedelta(days=140)).isoformat()
def plan_line(params: dict) -> list:
response = client.get(
f"{BASE}/weights/stats", params=params, headers=auth_headers
)
assert response.status_code == 200, response.text
body = response.json()
found = [s for s in body["series"] if s["name"] == "plan_line"]
assert found, "plan_line series missing"
assert body["meta"]["plan_end_date"] == expected_end
return found[0]["points"]
wide = plan_line({"from": start.isoformat(), "to": today.isoformat()})
narrow = plan_line(
{"from": (today - dt.timedelta(days=2)).isoformat(), "to": today.isoformat()}
)
assert wide == narrow, "plan_line must not depend on the requested window"
assert wide[0] == [start.isoformat(), 95.0]
assert wide[1] == [expected_end, 85.0]
def test_plan_line_uses_target_date_when_the_goal_has_one(
client: TestClient, auth_headers: dict[str, str]
) -> None:
_seed(client, auth_headers)
today = _today()
start = today - dt.timedelta(days=6)
target_date = today + dt.timedelta(days=200)
assert (
client.post(
f"{BASE}/goals",
json={
"mode": "target_date",
"start_date": start.isoformat(),
"start_weight_kg": 95.0,
"target_weight_kg": 85.0,
"target_date": target_date.isoformat(),
},
headers=auth_headers,
).status_code
== 201
)
body = client.get(
f"{BASE}/weights/stats",
params={"from": start.isoformat(), "to": today.isoformat()},
headers=auth_headers,
).json()
points = next(s for s in body["series"] if s["name"] == "plan_line")["points"]
assert points[1] == [target_date.isoformat(), 85.0]
def test_maintain_goal_has_no_plan_line(
client: TestClient, auth_headers: dict[str, str]
) -> None:
_seed(client, auth_headers)
today = _today()
assert (
client.post(
f"{BASE}/goals",
json={
"mode": "maintain",
"start_date": (today - dt.timedelta(days=6)).isoformat(),
"start_weight_kg": 95.0,
"target_weight_kg": 95.0,
},
headers=auth_headers,
).status_code
== 201
)
body = client.get(f"{BASE}/weights/stats", headers=auth_headers).json()
assert [s for s in body["series"] if s["name"] == "plan_line"] == []
assert body["meta"]["plan_end_date"] is None
# --- 3. deficit_target_kcal is the AIMED deficit, floor or not ----------------
def test_deficit_target_is_the_goal_target_even_when_the_floor_applies(
client: TestClient, auth_headers: dict[str, str]
) -> None:
_seed(client, auth_headers)
today = _today()
assert (
client.post(
f"{BASE}/goals",
json={
"mode": "weekly_rate",
"start_date": (today - dt.timedelta(days=6)).isoformat(),
"start_weight_kg": 95.0,
"target_weight_kg": 85.0,
"weekly_rate_kg": 1.5,
},
headers=auth_headers,
).status_code
== 201
)
budget = client.get(f"{BASE}/goals/active", headers=auth_headers).json()["budget"]
# 1.5 kg/week -> 1.5 * 7700 / 7 = 1650 kcal/day, whatever the floor does.
assert budget["deficit_target_kcal"] == 1650.0
assert budget["floor_applied"] is True
assert budget["kcal"] == 1500.0
def test_energy_balance_meta_exposes_the_target_deficit(
client: TestClient, auth_headers: dict[str, str]
) -> None:
_seed(client, auth_headers)
today = _today()
assert (
client.post(
f"{BASE}/goals",
json={
"mode": "weekly_rate",
"start_date": (today - dt.timedelta(days=6)).isoformat(),
"start_weight_kg": 95.0,
"target_weight_kg": 85.0,
"weekly_rate_kg": 0.5,
},
headers=auth_headers,
).status_code
== 201
)
meta = client.get(
f"{BASE}/energy-balance",
params={
"from": (today - dt.timedelta(days=6)).isoformat(),
"to": today.isoformat(),
},
headers=auth_headers,
).json()["meta"]
assert meta["deficit_target_kcal"] == 550.0 # 0.5 * 7700 / 7
# --- 4. The TDEE KPI sub-label needs bmr / factor / mode ----------------------
def test_energy_balance_meta_explains_how_the_tdee_was_obtained(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""`tdee_mode` + `bmr_kcal` + `activity_factor` back the « BMR x facteur »
sub-label of the TDEE KPI, which could never render without them."""
_seed(client, auth_headers)
today = _today()
meta = client.get(
f"{BASE}/energy-balance",
params={
"from": (today - dt.timedelta(days=6)).isoformat(),
"to": today.isoformat(),
},
headers=auth_headers,
).json()["meta"]
assert meta["tdee_mode"] == "estimated" # no activity_daily seeded
assert meta["activity_factor"] == 1.55 # profile activity_level = moderate
assert meta["bmr_kcal"] is not None
tdee_avg = meta["bmr_kcal"] * meta["activity_factor"]
assert tdee_avg > 1500
def test_energy_balance_meta_reports_a_measured_tdee_mode(
client: TestClient, auth_headers: dict[str, str]
) -> None:
_seed(client, auth_headers)
today = _today()
for offset in range(7):
day = today - dt.timedelta(days=offset)
assert client.post(
f"{BASE}/activity",
json={"date": day.isoformat(), "steps": 12000, "active_kcal": 600},
headers=auth_headers,
).status_code in (200, 201)
meta = client.get(
f"{BASE}/energy-balance",
params={
"from": (today - dt.timedelta(days=6)).isoformat(),
"to": today.isoformat(),
},
headers=auth_headers,
).json()["meta"]
assert meta["tdee_mode"] == "bmr_plus_active"
# --- 5. The protein gauge of the nutrition journal ----------------------------
def test_nutrition_day_detail_exposes_the_protein_goal(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""« Protéines aujourd'hui : 96 / 130 g » needs a target (ux-pages §15.3,
1,6 g/kg) — the journal endpoint never sent one, so the gauge stayed at —."""
_seed(client, auth_headers)
today = _today()
body = client.get(
f"{BASE}/nutrition/days/{today.isoformat()}", headers=auth_headers
).json()
trend = client.get(f"{BASE}/dashboard", headers=auth_headers).json()[
"trend_weight_kg"
]
assert body["protein_goal_g"] == round(trend * 1.6, 1)
def test_protein_goal_is_absent_without_any_weigh_in(
client: TestClient, auth_headers: dict[str, str]
) -> None:
today = _today()
body = client.get(
f"{BASE}/nutrition/days/{today.isoformat()}", headers=auth_headers
).json()
assert body["protein_goal_g"] is None
# --- 6. « Dernière pesée » must mean the same thing everywhere ----------------
def test_last_weight_is_the_most_recent_weigh_in_of_the_day(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""/weights/stats reported the FIRST weigh-in of the last day (the series
rule of §5.5) while /dashboard reported the LAST one — same French label,
two different numbers."""
assert (
client.put(f"{BASE}/profile", json=PROFILE, headers=auth_headers).status_code
== 200
)
yesterday = _today() - dt.timedelta(days=1)
for hour, weight in ((7, 92.14), (18, 93.6)):
assert (
client.post(
f"{BASE}/weights",
json={"measured_at": _at(yesterday, hour), "weight_kg": weight},
headers=auth_headers,
).status_code
== 201
)
stats = client.get(f"{BASE}/weights/stats", headers=auth_headers).json()
dashboard = client.get(f"{BASE}/dashboard", headers=auth_headers).json()
assert stats["meta"]["last_weight_kg"] == 93.6
assert dashboard["weight_kg"] == 93.6
# the trend series still uses the first weigh-in of the day (§5.5)
raw = next(s for s in stats["series"] if s["name"] == "weight_raw")
assert raw["points"][-1][1] == 92.14