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>
276 lines
8.8 KiB
Python
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
|