32461b7405
- services/off.py: single normalizer module (kcal/kJ fallback, not-found
rule); fetch_product/search_off degrade gracefully on upstream errors
(503/timeout → None / []), never 500; User-Agent + timeouts on all calls
- routers/off.py: thin routes for GET /api/off/product/{barcode},
GET /api/off/search, POST /api/off/refresh/{food_id} (404 unknown id,
400 no-barcode)
- services/foods.py: restore-on-rescan (§3.1) — barcode collision on a
soft-deleted food clears deleted_at + updates row instead of 409;
GET /api/foods/recent ordered by most-recent daily_log appearance,
deduped, soft-deleted excluded
- tests: httpx mocked at the boundary (MockTransport, no real OFF);
48 new tests (normalizer unit + router + restore + recent + error paths)
- 152 passing (was 104)
536 lines
19 KiB
Python
536 lines
19 KiB
Python
"""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="<html>Service Unavailable</html>")
|
|
|
|
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="<html>Service Unavailable</html>")
|
|
|
|
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="<html>Service Unavailable</html>")
|
|
|
|
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="<html>Service Unavailable</html>")
|
|
|
|
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
|