"""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 # --- 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