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>
443 lines
15 KiB
Python
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
|