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

229 lines
7.5 KiB
Python

"""Food search: local `food_items` cache first, Open Food Facts proxy next.
Every OFF call is served by an httpx.MockTransport — the suite never reaches
the network.
"""
from collections.abc import Iterator
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.health import foods
from app.modules.health.models import FoodItem
URL = "/api/health/foods/search"
OFF_HIT = {
"code": "3175680011480",
"product_name": "Petit Beurre",
"product_name_fr": "Véritable Petit Beurre",
"brands": "LU, Mondelez",
"serving_quantity": 8.3,
"nutriments": {
"energy-kcal_100g": 442,
"proteins_100g": 7.2,
"carbohydrates_100g": 74.5,
"sugars_100g": 22.1,
"fat_100g": 12.3,
"saturated-fat_100g": 6.4,
"fiber_100g": 2.6,
"salt_100g": 0.9,
},
}
@pytest.fixture(autouse=True)
def _reset_transport() -> Iterator[None]:
yield
foods.set_http_transport(None)
def use_transport(handler) -> list[httpx.Request]:
"""Install a MockTransport and return the list of captured requests."""
seen: list[httpx.Request] = []
def wrapped(request: httpx.Request) -> httpx.Response:
seen.append(request)
return handler(request)
foods.set_http_transport(httpx.MockTransport(wrapped))
return seen
def ok_search(hits: list[dict]):
return lambda request: httpx.Response(200, json={"hits": hits, "count": len(hits)})
# --- Normalisation -------------------------------------------------------------
def test_normalize_prefers_the_french_name_and_first_brand() -> None:
data = foods.normalize_off_product(OFF_HIT)
assert data["name"] == "Véritable Petit Beurre"
assert data["brand"] == "LU"
assert float(data["energy_kcal_100g"]) == 442.0
assert float(data["salt_g_100g"]) == 0.9
assert float(data["serving_size_g"]) == 8.3
def test_normalize_recomputes_kcal_from_kilojoules() -> None:
data = foods.normalize_off_product(
{"code": "1", "product_name": "Truc", "nutriments": {"energy_100g": 1000}}
)
assert float(data["energy_kcal_100g"]) == pytest.approx(239.0, abs=0.1)
def test_normalize_skips_nameless_products() -> None:
assert foods.normalize_off_product({"code": "1", "nutriments": {}}) is None
# --- Search --------------------------------------------------------------------
def test_search_queries_off_and_caches_the_hit(
client: TestClient, db: Session, auth_headers: dict[str, str]
) -> None:
seen = use_transport(ok_search([OFF_HIT]))
body = client.get(URL, params={"q": "petit beurre"}, headers=auth_headers).json()
assert body["origin"] == "cache+off"
assert body["items"][0]["name"] == "Véritable Petit Beurre"
assert body["items"][0]["source"] == "off"
assert body["items"][0]["source_id"] == "3175680011480"
request = seen[0]
assert request.url.host == "search.openfoodfacts.org"
assert request.url.params["q"] == "petit beurre"
assert request.url.params["langs"] == "fr"
assert request.headers["User-Agent"] == "LifeTrack/1.0 (meejayproduction@gmail.com)"
cached = db.scalars(select(FoodItem)).all()
assert len(cached) == 1
assert cached[0].source_id == "3175680011480"
def test_second_search_is_served_from_the_cache(
client: TestClient, db: Session, auth_headers: dict[str, str]
) -> None:
db.add(
FoodItem(
source="off",
source_id="3175680011480",
name="Véritable Petit Beurre",
brand="LU",
)
)
db.commit()
seen = use_transport(ok_search([OFF_HIT]))
body = client.get(
URL, params={"q": "beurre", "limit": 1}, headers=auth_headers
).json()
assert body["origin"] == "cache"
assert len(body["items"]) == 1
assert seen == [] # OFF was never called
def test_cached_products_are_not_duplicated(
client: TestClient, db: Session, auth_headers: dict[str, str]
) -> None:
use_transport(ok_search([OFF_HIT]))
client.get(URL, params={"q": "petit beurre"}, headers=auth_headers)
client.get(URL, params={"q": "petit beurre"}, headers=auth_headers)
assert len(db.scalars(select(FoodItem)).all()) == 1
def test_search_matches_the_brand_locally(
client: TestClient, db: Session, auth_headers: dict[str, str]
) -> None:
db.add(FoodItem(source="ciqual", source_id="1234", name="Pomme, crue", brand=None))
db.commit()
use_transport(ok_search([]))
body = client.get(URL, params={"q": "pomme"}, headers=auth_headers).json()
assert [item["name"] for item in body["items"]] == ["Pomme, crue"]
def test_search_requires_two_characters(
client: TestClient, auth_headers: dict[str, str]
) -> None:
response = client.get(URL, params={"q": "a"}, headers=auth_headers)
assert response.status_code == 400
assert "2 caractères" in response.json()["error"]["message"]
def test_search_requires_authentication(client: TestClient) -> None:
assert client.get(URL, params={"q": "pomme"}).status_code == 401
# --- Degraded mode -------------------------------------------------------------
def test_unreachable_off_returns_a_french_503(
client: TestClient, auth_headers: dict[str, str]
) -> None:
def boom(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom", request=request)
use_transport(boom)
response = client.get(URL, params={"q": "petit beurre"}, headers=auth_headers)
assert response.status_code == 503
error = response.json()["error"]
assert error["code"] == "service_unavailable"
assert "Open Food Facts" in error["message"]
assert "injoignable" in error["message"]
def test_unreachable_off_still_serves_local_results(
client: TestClient, db: Session, auth_headers: dict[str, str]
) -> None:
db.add(FoodItem(source="ciqual", source_id="1234", name="Pomme, crue"))
db.commit()
def boom(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("boom", request=request)
use_transport(boom)
body = client.get(URL, params={"q": "pomme"}, headers=auth_headers).json()
assert body["origin"] == "cache"
assert len(body["items"]) == 1
def test_off_http_error_is_degraded_too(
client: TestClient, auth_headers: dict[str, str]
) -> None:
use_transport(lambda request: httpx.Response(429, json={"error": "rate limited"}))
response = client.get(URL, params={"q": "petit beurre"}, headers=auth_headers)
assert response.status_code == 503
# --- Barcode -------------------------------------------------------------------
def test_barcode_lookup_caches_and_then_hits_the_cache(
client: TestClient, db: Session, auth_headers: dict[str, str]
) -> None:
seen = use_transport(
lambda request: httpx.Response(200, json={"status": 1, "product": OFF_HIT})
)
body = client.get(
"/api/health/foods/barcode/3175680011480", headers=auth_headers
).json()
assert body["name"] == "Véritable Petit Beurre"
assert len(seen) == 1
assert seen[0].url.host == "world.openfoodfacts.org"
client.get("/api/health/foods/barcode/3175680011480", headers=auth_headers)
assert len(seen) == 1 # served from food_items
assert len(db.scalars(select(FoodItem)).all()) == 1
def test_unknown_barcode_is_a_french_404(
client: TestClient, auth_headers: dict[str, str]
) -> None:
use_transport(lambda request: httpx.Response(200, json={"status": 0}))
response = client.get("/api/health/foods/barcode/0000", headers=auth_headers)
assert response.status_code == 404
assert "code-barres" in response.json()["error"]["message"]