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

276 lines
8.8 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`).
"""
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