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

262 lines
8.7 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_a_partially_warm_cache_still_returns_a_full_page(
client: TestClient, db: Session, auth_headers: dict[str, str]
) -> None:
"""OFF results overlap the local cache, so the page must be over-fetched.
Regression: `page_size` used to be `limit - len(local)`, and the overlapping
products were then skipped as already known — a warm cache under-delivered.
"""
for index in range(3):
db.add(
FoodItem(
source="off", source_id=f"off-{index}", name=f"Pomme variété {index}"
)
)
db.commit()
def handler(request: httpx.Request) -> httpx.Response:
size = int(request.url.params["page_size"])
hits = [
{
"code": f"off-{index}",
"product_name": f"Pomme variété {index}",
"nutriments": {"energy-kcal_100g": 52},
}
for index in range(size)
]
return httpx.Response(200, json={"hits": hits, "count": size})
use_transport(handler)
body = client.get(URL, params={"q": "pomme", "limit": 5}, headers=auth_headers)
assert len(body.json()["items"]) == 5
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"]