"""OFF proxy tests — mock at the httpx boundary (spec §8.4). Never hits the real OpenFoodFacts API. Uses httpx.MockTransport (built into httpx, no extra dep). """ import json import httpx import pytest from services.off import ( fetch_product, normalize_off_product, search_off, ) # ── Reusable OFF product fixtures ──────────────────────────────────────────── def _make_off_product(**overrides): """Build a minimal but realistic OFF v2 product dict. Default: Nutella-like product with kcal, protein, carbs, fat, and brand. """ p = { "code": "3017620422003", "product_name": "Nutella", "generic_name": "Pâte à tartiner aux noisettes et au cacao", "brands": "Nutella, Ferrero, Yum yum", "serving_quantity": None, "serving_size": None, "nutriments": { "energy-kcal_100g": 539, "energy-kj_100g": 2252, "proteins_100g": 6.3, "carbohydrates_100g": 57.5, "fat_100g": 30.9, "fiber_100g": None, # not present in real Nutella "saturated-fat_100g": 10.6, "sugars_100g": 56.3, "sodium_100g": 0.0428, }, } p.update(overrides) return p def _make_off_response(status=1, product=None): """Build an OFF v2 API response envelope.""" return {"status": status, "code": (product or {}).get("code", ""), "product": product} def _mock_client(handler): """Create an httpx.Client backed by MockTransport with the given handler. handler: callable(request) -> httpx.Response """ return httpx.Client(transport=httpx.MockTransport(handler)) # ── Normalizer unit tests ──────────────────────────────────────────────────── class TestNormalizeOffProduct: """Normalizer tests on hand-constructed OFF payloads (§8.4).""" def test_kcal_present(self): """When energy-kcal_100g is present, use it directly.""" prod = _make_off_product() result = normalize_off_product(prod) assert result is not None assert result["name"] == "Nutella" assert result["calories_per_unit"] == 539 assert result["source"] == "openfoodfacts" assert result["unit_type"] == "weight" assert result["is_meal"] is False def test_kj_fallback(self): """When kcal is absent, convert kJ → kcal (1 kcal = 4.184 kJ).""" prod = _make_off_product() del prod["nutriments"]["energy-kcal_100g"] # 2252 kJ / 4.184 ≈ 538.3 → rounded to 1dp = 538.2 result = normalize_off_product(prod) assert result is not None assert result["calories_per_unit"] == round(2252 / 4.184, 1) def test_kj_fallback_value(self): """Precise check: 1000 kJ → 239.0 kcal.""" prod = _make_off_product() prod["nutriments"] = {"energy-kj_100g": 1000} result = normalize_off_product(prod) assert result is not None assert result["calories_per_unit"] == round(1000 / 4.184, 1) def test_missing_nutriments_become_null(self): """Absent nutriment keys → None (not crash).""" prod = _make_off_product() prod["nutriments"] = {"energy-kcal_100g": 100} result = normalize_off_product(prod) assert result is not None assert result["protein_per_unit"] is None assert result["carbs_per_unit"] is None assert result["fat_per_unit"] is None assert result["fiber_per_unit"] is None assert result["saturated_fat_per_unit"] is None assert result["sugars_per_unit"] is None assert result["sodium_per_unit"] is None def test_empty_nutriments(self): """Empty dict nutriments → all None.""" prod = _make_off_product() prod["nutriments"] = {} result = normalize_off_product(prod) assert result is not None assert result["calories_per_unit"] is None def test_no_name_no_calories(self): """No usable name AND no calories → None (not-found).""" prod = _make_off_product() prod["product_name"] = "" prod["generic_name"] = "" prod["nutriments"] = {} result = normalize_off_product(prod) assert result is None def test_no_name_but_has_calories(self): """No name but has calories → still usable.""" prod = _make_off_product() prod["product_name"] = "" prod["generic_name"] = "" # kcal present result = normalize_off_product(prod) assert result is not None assert result["name"] == "" def test_has_name_no_calories(self): """Has name but no calories → still usable.""" prod = _make_off_product() prod["nutriments"] = {} result = normalize_off_product(prod) assert result is not None assert result["name"] == "Nutella" assert result["calories_per_unit"] is None def test_brand_first_entry(self): """Comma-separated brands → first trimmed entry.""" prod = _make_off_product() prod["brands"] = " Nutella , Ferrero , Yum yum " result = normalize_off_product(prod) assert result["brand"] == "Nutella" def test_brand_single(self): """Single brand, no commas.""" prod = _make_off_product() prod["brands"] = "Nestlé" result = normalize_off_product(prod) assert result["brand"] == "Nestlé" def test_brand_empty(self): """Empty brands string → None.""" prod = _make_off_product() prod["brands"] = "" result = normalize_off_product(prod) assert result["brand"] is None def test_brand_none(self): """Missing brands key → None.""" prod = _make_off_product() del prod["brands"] result = normalize_off_product(prod) assert result["brand"] is None def test_generic_name_fallback(self): """When product_name is empty, fall back to generic_name.""" prod = _make_off_product() prod["product_name"] = "" prod["generic_name"] = "Hazelnut cocoa spread" result = normalize_off_product(prod) assert result["name"] == "Hazelnut cocoa spread" def test_serving_fields(self): """serving_quantity → serving_size_g; serving_size → serving_name.""" prod = _make_off_product() prod["serving_quantity"] = 15 prod["serving_size"] = "1 tbsp (15g)" result = normalize_off_product(prod) assert result["serving_size_g"] == 15.0 assert result["serving_name"] == "1 tbsp (15g)" def test_serving_quantity_string(self): """serving_quantity as string → parsed to float.""" prod = _make_off_product() prod["serving_quantity"] = "30" result = normalize_off_product(prod) assert result["serving_size_g"] == 30.0 def test_serving_quantity_invalid(self): """Non-numeric serving_quantity → None.""" prod = _make_off_product() prod["serving_quantity"] = "abc" result = normalize_off_product(prod) assert result["serving_size_g"] is None def test_off_data_included(self): """Raw product JSON is stored in off_data.""" prod = _make_off_product() result = normalize_off_product(prod) assert result is not None assert "off_data" in result parsed = json.loads(result["off_data"]) assert parsed["code"] == "3017620422003" assert parsed["product_name"] == "Nutella" def test_barcode_from_code_field(self): prod = _make_off_product(code="1234567890123") # _make_off_product puts code in the product dict directly # but the OFF envelope has code at top level too. # normalize_off_product reads product["code"] result = normalize_off_product(prod) assert result is not None assert result["barcode"] == "1234567890123" # ── fetch_product tests ────────────────────────────────────────────────────── class TestFetchProduct: """GET /api/off/product/{barcode} — mock at httpx boundary.""" def test_found(self): """Status=1 with product → normalized dict.""" prod = _make_off_product() def handler(request): return httpx.Response(200, json=_make_off_response(1, prod)) client = _mock_client(handler) result = fetch_product("3017620422003", client=client) assert result is not None assert result["name"] == "Nutella" assert result["calories_per_unit"] == 539 assert result["source"] == "openfoodfacts" def test_not_found_status_zero(self): """Status=0 → None.""" def handler(request): return httpx.Response(200, json={"status": 0, "code": "x", "product": None}) result = fetch_product("000", client=_mock_client(handler)) assert result is None def test_not_found_no_product(self): """Status=1 but no product key → None.""" def handler(request): return httpx.Response(200, json={"status": 1, "code": "x"}) result = fetch_product("000", client=_mock_client(handler)) assert result is None def test_unusable_product(self): """Product with no name and no calories → None.""" prod = _make_off_product() prod["product_name"] = "" prod["generic_name"] = "" prod["nutriments"] = {} def handler(request): return httpx.Response(200, json=_make_off_response(1, prod)) result = fetch_product("x", client=_mock_client(handler)) assert result is None def test_upstream_503_returns_none(self): """Upstream HTTP 503 → None (graceful, no crash).""" def handler(request): return httpx.Response(503, html="Service Unavailable") result = fetch_product("x", client=_mock_client(handler)) assert result is None # ── search_off tests ───────────────────────────────────────────────────────── class TestSearchOff: """GET /api/off/search?q= — mock at httpx boundary.""" def test_returns_normalized_list(self): """Search hits → list of normalized dicts.""" def handler(request): return httpx.Response(200, json={ "count": 2, "products": [ _make_off_product(code="1", product_name="Alpha"), _make_off_product(code="2", product_name="Beta"), ], }) result = search_off("test", client=_mock_client(handler)) assert len(result) == 2 assert result[0]["name"] == "Alpha" assert result[1]["name"] == "Beta" def test_empty_results(self): """No products → empty list.""" def handler(request): return httpx.Response(200, json={"count": 0, "products": []}) result = search_off("nothing", client=_mock_client(handler)) assert result == [] def test_filters_unusable(self): """Unusable products (no name + no calories) are filtered out.""" usable = _make_off_product(code="1", product_name="Good") unusable = _make_off_product(code="2") unusable["product_name"] = "" unusable["generic_name"] = "" unusable["nutriments"] = {} def handler(request): return httpx.Response(200, json={ "count": 2, "products": [unusable, usable], }) result = search_off("test", client=_mock_client(handler)) assert len(result) == 1 assert result[0]["name"] == "Good" def test_upstream_503_returns_empty(self): """Upstream HTTP 503 → graceful empty list, not a crash.""" def handler(request): return httpx.Response(503, html="Service Unavailable") result = search_off("test", client=_mock_client(handler)) assert result == [] def test_connect_error_returns_empty(self): """Transport failure (ConnectError) → graceful empty list.""" def handler(request): raise httpx.ConnectError("connection refused") result = search_off("test", client=_mock_client(handler)) assert result == [] # ── Integration-style tests via TestClient ──────────────────────────────────── # These test the full router → service path with mocked httpx at the boundary. # The off_mock fixture (defined in conftest.py) patches _default_client(). class TestOffProductEndpoint: """GET /api/off/product/{barcode}""" def test_found(self, client, off_mock): prod = _make_off_product() def handler(request): return httpx.Response(200, json=_make_off_response(1, prod)) off_mock.handler = handler resp = client.get("/api/off/product/3017620422003") assert resp.status_code == 200 data = resp.json() assert data["name"] == "Nutella" assert data["calories_per_unit"] == 539 assert data["source"] == "openfoodfacts" def test_not_found(self, client, off_mock): def handler(request): return httpx.Response(200, json={"status": 0, "code": "x", "product": None}) off_mock.handler = handler resp = client.get("/api/off/product/0000000000000") assert resp.status_code == 404 def test_returns_normalized_not_raw(self, client, off_mock): """Response is normalized (FoodCreate shape), not raw OFF JSON.""" prod = _make_off_product() def handler(request): return httpx.Response(200, json=_make_off_response(1, prod)) off_mock.handler = handler resp = client.get("/api/off/product/3017620422003") assert resp.status_code == 200 data = resp.json() # These are our normalized keys — not raw OFF field names assert "calories_per_unit" in data assert "protein_per_unit" in data assert "serving_size_g" in data assert "off_data" in data # Raw OFF keys should NOT be at top level assert "nutriments" not in data assert "product_name" not in data def test_upstream_503_returns_404(self, client, off_mock): """Upstream 503 → 404 (graceful, not 500).""" def handler(request): return httpx.Response(503, html="Service Unavailable") off_mock.handler = handler resp = client.get("/api/off/product/3017620422003") assert resp.status_code == 404 class TestOffSearchEndpoint: """GET /api/off/search?q=""" def test_returns_list(self, client, off_mock): def handler(request): return httpx.Response(200, json={ "count": 2, "products": [ _make_off_product(code="1", product_name="Alpha"), _make_off_product(code="2", product_name="Beta"), ], }) off_mock.handler = handler resp = client.get("/api/off/search?q=test") assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) == 2 assert data[0]["name"] == "Alpha" def test_missing_q(self, client): """q query param is required.""" resp = client.get("/api/off/search") assert resp.status_code == 422 def test_empty_q(self, client): """Empty q → 422.""" resp = client.get("/api/off/search?q=") assert resp.status_code == 422 def test_empty_results(self, client, off_mock): def handler(request): return httpx.Response(200, json={"count": 0, "products": []}) off_mock.handler = handler resp = client.get("/api/off/search?q=nothing") assert resp.status_code == 200 assert resp.json() == [] def test_upstream_503_returns_empty_list(self, client, off_mock): """Upstream 503 → HTTP 200 with [] (not 500).""" def handler(request): return httpx.Response(503, html="Service Unavailable") off_mock.handler = handler resp = client.get("/api/off/search?q=zzzznonexistentfood12345") assert resp.status_code == 200 assert resp.json() == [] def test_connect_error_returns_empty_list(self, client, off_mock): """Transport failure → HTTP 200 with [] (not 500).""" def handler(request): raise httpx.ConnectError("connection refused") off_mock.handler = handler resp = client.get("/api/off/search?q=anything") assert resp.status_code == 200 assert resp.json() == [] class TestOffRefreshEndpoint: """POST /api/off/refresh/{food_id}""" def test_updates_food(self, client, off_mock): """Refresh re-fetches from OFF and updates the local row.""" # Create a food with a barcode resp = client.post("/api/foods", json={ "name": "Old Name", "barcode": "refresh-test", "calories_per_unit": 100, "source": "openfoodfacts", "unit_type": "weight", }) assert resp.status_code == 201 fid = resp.json()["id"] # Mock OFF to return different data updated_prod = _make_off_product( code="refresh-test", product_name="New Name", ) updated_prod["nutriments"]["energy-kcal_100g"] = 200 def handler(request): return httpx.Response(200, json=_make_off_response(1, updated_prod)) off_mock.handler = handler resp = client.post(f"/api/off/refresh/{fid}") assert resp.status_code == 200 data = resp.json() assert data["name"] == "New Name" assert data["calories_per_unit"] == 200 assert data["id"] == fid def test_unknown_food_id(self, client): """404 for unknown food_id.""" resp = client.post("/api/off/refresh/99999") assert resp.status_code == 404 def test_no_barcode(self, client): """400 when the food has no barcode.""" resp = client.post("/api/foods", json={ "name": "No Barcode Food", "calories_per_unit": 100, }) fid = resp.json()["id"] resp = client.post(f"/api/off/refresh/{fid}") assert resp.status_code == 400 assert "barcode" in resp.json()["detail"].lower() def test_off_no_longer_has_product(self, client, off_mock): """404 when OFF returns status=0 for the barcode.""" resp = client.post("/api/foods", json={ "name": "Will Disappear", "barcode": "will-be-gone", "calories_per_unit": 100, }) fid = resp.json()["id"] def handler(request): return httpx.Response(200, json={"status": 0, "code": "will-be-gone", "product": None}) off_mock.handler = handler resp = client.post(f"/api/off/refresh/{fid}") assert resp.status_code == 404