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