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>
394 lines
13 KiB
Python
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
|