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>
262 lines
8.7 KiB
Python
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"]
|