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

443 lines
15 KiB
Python

"""Cross-module API contract tests (architecture §8, CONVENTIONS C6).
These assert the rules every module must honour, so a regression in one module
is caught even if that module's own suite still passes: the `Z` suffix on every
datetime, the single error envelope, the `Page[T]` envelope on unbounded lists,
the chart-ready `stats/*` shape, and the presence of the dashboards the home
page calls.
"""
import re
from collections.abc import Callable
from typing import Any
from fastapi.testclient import TestClient
from app.core.module_loader import iter_mounted_routes
from app.main import app
# An ISO datetime whose time part carries no offset at all.
NAIVE_ISO = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?$")
# A JSON string that is really a number — the tell-tale of an unserialised
# `Decimal` (Pydantic renders it as `"6.0"`, not `6.0`).
STRINGIFIED_NUMBER = re.compile(r"^-?\d+\.\d+$")
STATS_CONTRACT_KEYS = {"from", "to", "unit", "series", "meta"}
def _find_strings(node: Any, pattern: re.Pattern[str], path: str = "") -> list[str]:
"""Every `path = value` in a JSON tree whose string value matches `pattern`."""
if isinstance(node, dict):
return [
f for k, v in node.items() for f in _find_strings(v, pattern, f"{path}.{k}")
]
if isinstance(node, list):
return [
f
for i, v in enumerate(node)
for f in _find_strings(v, pattern, f"{path}[{i}]")
]
if isinstance(node, str) and pattern.match(node):
return [f"{path} = {node}"]
return []
def _naive_datetimes(node: Any, path: str = "") -> list[str]:
return _find_strings(node, NAIVE_ISO, path)
# --- Datetimes: UTC ISO 8601 with the `Z` suffix (§8.4) ------------------------
def _seed_every_module(client: TestClient, headers: dict[str, str]) -> None:
"""One row in each datetime-bearing table, so read paths return data."""
response = client.put(
"/api/health/profile",
headers=headers,
json={
"height_cm": 180.0,
"sex": "male",
"birthdate": "1990-08-13",
"activity_level": "moderate",
"timezone": "Europe/Paris",
},
)
assert response.status_code == 200, response.text
for path, payload in (
(
"/api/health/weights",
{"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 90.0},
),
(
"/api/health/measurements",
{"measured_at": "2026-08-13T07:00:00Z", "waist_cm": 90},
),
(
"/api/health/workouts",
{
"started_at": "2026-08-13T18:00:00Z",
"ended_at": "2026-08-13T19:00:00Z",
"sport_type": "running",
},
),
(
"/api/health/nutrition/entries",
{
"eaten_at": "2026-08-13T12:00:00Z",
"name": "Poulet",
"meal": "lunch",
"kcal": 500,
},
),
(
"/api/health/nutrition/water",
{"drunk_at": "2026-08-13T09:00:00Z", "volume_ml": 500},
),
("/api/health/nutrition/favorites", {"name": "Favori", "kcal": 100}),
("/api/health/activity", {"date": "2026-08-13", "steps": 9000}),
(
"/api/health/goals",
{
"mode": "weekly_rate",
"start_date": "2026-01-01",
"start_weight_kg": 95,
"target_weight_kg": 80,
"weekly_rate_kg": -0.5,
},
),
("/api/auth/device-keys", {"name": "Pont", "scopes": ["ingest:health"]}),
("/api/vape/coils", {"changed_at": "2026-08-13T07:00:00Z"}),
(
"/api/vape/liquids",
{"entry_date": "2026-08-13", "kind": "daily_total", "ml": 4.0},
),
("/api/finance/accounts", {"name": "Compte courant", "kind": "checking"}),
):
response = client.post(path, headers=headers, json=payload)
assert response.status_code in (200, 201), f"{path}: {response.text}"
csv = b"date;poids\n2026-08-01;91,2\n"
run = client.post(
"/api/imports",
headers=headers,
files={"file": ("poids.csv", csv, "text/csv")},
data={"source": "weight_generic_csv"},
)
assert run.status_code == 201, run.text
def test_no_endpoint_returns_a_naive_datetime(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""Sweeps every parameterless GET for datetimes missing their offset.
SQLite hands back naive datetimes for `DateTime(timezone=True)` columns, so
an unguarded read path emits `2026-08-13T18:58:59` — which `new Date(...)`
parses as *local* time in the browser, a silent 2 h shift in Paris.
"""
_configure_vape(client, auth_headers)
_seed_every_module(client, auth_headers)
offenders: list[str] = []
checked = 0
for path, operations in app.openapi()["paths"].items():
if "get" not in operations or "{" in path:
continue
response = client.get(path, headers=auth_headers)
if response.status_code != 200:
continue
checked += 1
offenders += [f"{path}{field}" for field in _naive_datetimes(response.json())]
assert offenders == []
assert checked > 30, checked
def test_no_endpoint_returns_a_number_as_a_string(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""`Numeric` columns must reach the frontend as JSON numbers.
Pydantic serialises a bare `Decimal` as a string, so `4.25` ml would arrive
as `"4.25"` — ECharts plots nothing and `Intl.NumberFormat` throws. Modules
wrap them (`finance.Money`, `vape.Quantity`) or expose floats (`health`).
"""
_configure_vape(client, auth_headers)
_seed_every_module(client, auth_headers)
offenders: list[str] = []
for path, operations in app.openapi()["paths"].items():
if "get" not in operations or "{" in path:
continue
response = client.get(path, headers=auth_headers)
if response.status_code != 200:
continue
offenders += [
f"{path}{field}"
for field in _find_strings(response.json(), STRINGIFIED_NUMBER)
]
assert offenders == []
def test_naive_datetime_input_is_refused(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""§8.4: naive input is refused (422) rather than silently read as UTC."""
response = client.post(
"/api/health/weights",
headers=auth_headers,
json={"measured_at": "2026-08-13T06:31:00", "weight_kg": 90.2},
)
assert response.status_code == 422, response.text
assert response.json()["error"]["code"] == "validation_error"
def test_offset_datetime_input_is_converted_to_utc(
client: TestClient, auth_headers: dict[str, str]
) -> None:
response = client.post(
"/api/health/weights",
headers=auth_headers,
json={"measured_at": "2026-08-13T08:31:00+02:00", "weight_kg": 90.2},
)
assert response.status_code == 201, response.text
assert response.json()["measured_at"] == "2026-08-13T06:31:00Z"
# --- Error envelope (§8.3) ----------------------------------------------------
def test_every_module_uses_the_single_error_envelope(client: TestClient) -> None:
for path in (
"/api/health/weights",
"/api/vape/liquids",
"/api/finance/transactions",
"/api/imports",
):
response = client.get(path)
assert response.status_code == 401, path
error = response.json()["error"]
assert set(error) == {"code", "message", "details"}, path
assert error["code"] == "unauthorized", path
# French, user-facing message (C1).
assert error["message"].endswith("."), path
def test_framework_errors_are_answered_in_french(client: TestClient) -> None:
"""Starlette fills `detail` with an English reason phrase ("Not Found",
"Method Not Allowed"); C1 forbids shipping it to the user."""
unknown = client.get("/api/inconnu")
assert unknown.status_code == 404
assert unknown.json()["error"] == {
"code": "not_found",
"message": "Ressource introuvable.",
"details": {},
}
wrong_method = client.delete("/api/healthz")
assert wrong_method.status_code == 405
error = wrong_method.json()["error"]
assert error["code"] == "method_not_allowed"
assert error["message"] == "Méthode non autorisée pour cette ressource."
# --- Home page: one dashboard per module (§7.1 of ux-pages) -------------------
def test_dashboard_endpoints_exist_for_every_business_module() -> None:
paths = {path for path, _ in iter_mounted_routes(app)}
for module in ("health", "vape", "finance"):
assert f"/api/{module}/dashboard" in paths, module
def test_health_and_finance_dashboards_work_on_a_blank_account(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""A brand-new user must not get a 500 on the home page."""
for path in ("/api/health/dashboard", "/api/finance/dashboard"):
response = client.get(path, headers=auth_headers)
assert response.status_code == 200, f"{path}: {response.text}"
def test_vape_dashboard_signals_setup_instead_of_failing(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""Vape needs a baseline before any figure means anything (§8.4 of the
datamodel): it answers 404 + `setup_required` so the home page can show the
« Module vape non configuré » empty state of ux-pages §16."""
response = client.get("/api/vape/dashboard", headers=auth_headers)
assert response.status_code == 404
error = response.json()["error"]
assert error["code"] == "not_found"
assert error["details"] == {"setup_required": True}
# --- Chart-ready stats contract (datamodel-health-vape §8.1) ------------------
def _configure_vape(client: TestClient, headers: dict[str, str]) -> None:
response = client.put(
"/api/vape/settings",
headers=headers,
json={
"quit_date": "2026-01-01",
"cigs_per_day_before": 20,
"cig_pack_price_cents": 1200,
"cigs_per_pack": 20,
"default_nicotine_mg_ml": 6.0,
"currency": "EUR",
},
)
assert response.status_code == 200, response.text
def test_health_and_vape_stats_follow_the_series_contract(
client: TestClient, auth_headers: dict[str, str]
) -> None:
_configure_vape(client, auth_headers)
endpoints = (
"/api/health/weights/stats",
"/api/health/measurements/stats",
"/api/health/activity/stats",
"/api/health/workouts/stats",
"/api/health/nutrition/stats",
"/api/health/nutrition/water/stats",
"/api/health/stats/adherence",
"/api/health/energy-balance",
"/api/vape/stats/consumption",
"/api/vape/stats/nicotine",
"/api/vape/stats/costs",
"/api/vape/stats/savings",
)
for path in endpoints:
response = client.get(path, headers=auth_headers)
assert response.status_code == 200, f"{path}: {response.text}"
body = response.json()
assert STATS_CONTRACT_KEYS <= set(body), path
assert isinstance(body["meta"], dict), path
for serie in body["series"]:
assert {"name", "type", "points"} <= set(serie), path
for point in serie["points"]:
# [date_iso, value|null] — ready for ECharts `dataset.source`.
assert len(point) == 2, f"{path}/{serie['name']}"
assert isinstance(point[0], str), f"{path}/{serie['name']}"
def test_finance_stats_follow_their_documented_shapes(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""datamodel-finance §9.8 defines per-endpoint shapes, not the §8.1 envelope."""
expected = {
"/api/finance/stats/monthly-by-category": {"months", "series", "totals"},
"/api/finance/stats/cashflow": {
"months",
"income",
"expenses",
"net",
"cumulative_net",
},
"/api/finance/stats/top-merchants": {"period", "items"},
"/api/finance/stats/recurring": {"items", "monthly_total_estimate"},
"/api/finance/stats/budget-progress": {"month", "items", "totals"},
"/api/finance/stats/sankey": {"period", "nodes", "links"},
}
for path, keys in expected.items():
response = client.get(path, headers=auth_headers)
assert response.status_code == 200, f"{path}: {response.text}"
assert keys <= set(response.json()), path
# --- Pagination envelope (C2.8) ----------------------------------------------
def test_unbounded_lists_use_the_page_envelope(
client: TestClient, auth_headers: dict[str, str]
) -> None:
"""Growing collections are paginated. Bounded config/selector lists
(`/finance/categories` tree, `/imports/sources`, `/vape/milestones`,
`/health/schedules`, `/finance/rules` — whose `reorder` takes the whole
ordered set) stay plain arrays on purpose."""
for path in (
"/api/health/weights",
"/api/health/measurements",
"/api/health/workouts",
"/api/health/nutrition/entries",
"/api/health/nutrition/water",
"/api/health/goals",
"/api/vape/liquids",
"/api/vape/products",
"/api/vape/coils",
"/api/vape/purchases",
"/api/finance/transactions",
"/api/imports",
):
response = client.get(path, headers=auth_headers)
assert response.status_code == 200, f"{path}: {response.text}"
body = response.json()
assert {"items", "total", "page", "page_size"} <= set(body), path
# --- Ingest (§5.5) ------------------------------------------------------------
def test_unknown_ingest_domain_is_404(
client: TestClient, auth_headers: dict[str, str]
) -> None:
response = client.post(
"/api/ingest/unknown-domain",
headers=auth_headers,
json={
"source": "probe",
"records": [
{
"type": "weight",
"data": {"measured_at": "2026-08-13T06:31:00Z", "weight_kg": 90.0},
}
],
},
)
assert response.status_code == 404, response.text
assert response.json()["error"]["code"] == "not_found"
# --- Auth boundary (C2.7) -----------------------------------------------------
def test_business_endpoints_reject_anonymous_calls(client: TestClient) -> None:
"""Every documented route except the public auth ones and the probes."""
public = {
"/api/auth/status",
"/api/auth/setup",
"/api/auth/login",
"/api/healthz",
"/healthz",
"/openapi.json",
"/api/openapi.json",
"/api/docs",
}
schema = app.openapi()
unprotected: list[str] = []
checked = 0
caller: dict[str, Callable[..., Any]] = {
"get": client.get,
"post": client.post,
"patch": client.patch,
"put": client.put,
"delete": client.delete,
}
for path, operations in schema["paths"].items():
if path in public or "{" in path:
continue
for verb, call in caller.items():
if verb not in operations:
continue
response = call(path)
checked += 1
if response.status_code != 401:
unprotected.append(f"{verb.upper()} {path} -> {response.status_code}")
assert unprotected == []
# Guard against the sweep silently becoming vacuous.
assert checked > 50, checked