TICKET-006 (backend): OFF normalization, scan/search/refresh, restore-on-rescan

- 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)
This commit is contained in:
Craig
2026-07-26 15:46:24 +01:00
parent f8048da9c1
commit 32461b7405
7 changed files with 1095 additions and 46 deletions
+32
View File
@@ -3,6 +3,7 @@
import os
import tempfile
import httpx
import pytest
# Must be set before any app module is imported (engine binds at import time)
@@ -20,3 +21,34 @@ def client():
run_migrations()
with TestClient(app) as c:
yield c
@pytest.fixture
def off_mock(monkeypatch):
"""Replace _default_client() in services.off with a mock client backed
by httpx.MockTransport. Tests assign off_mock.handler to control
responses.
Usage:
def test_foo(client, off_mock):
def handler(request):
return httpx.Response(200, json={...})
off_mock.handler = handler
resp = client.get("/api/off/product/123")
"""
state = _MockState()
def _make_mock_client():
return httpx.Client(
transport=httpx.MockTransport(lambda req: state.handler(req))
)
import services.off as off_mod
monkeypatch.setattr(off_mod, "_default_client", _make_mock_client)
return state
class _MockState:
"""Mutable state so tests can set .handler after fixture injection."""
def __init__(self):
self.handler = None
+116 -6
View File
@@ -165,15 +165,27 @@ def test_barcode_conflict_on_live_food(client):
assert "barcode" in resp.json()["detail"].lower()
def test_barcode_conflict_on_deleted_food(client):
"""Creating a food with a barcode that exists on a DELETED food → also 409
for now (restore-on-rescan is TICKET-006)."""
resp = create_food(client, name="First", barcode="conflict-on-deleted")
def test_restore_on_rescan(client):
"""Creating a food with a barcode that belongs to a SOFT-DELETED food
restores the deleted row (clears deleted_at, updates fields) instead
of inserting a duplicate (§3.1 restore-on-rescan)."""
# Create a food, then soft-delete it
resp = create_food(client, name="Original", barcode="restore-me", calories_per_unit=100)
fid = resp.json()["id"]
client.delete(f"/api/foods/{fid}")
resp = create_food(client, name="Second", barcode="conflict-on-deleted")
assert resp.status_code == 409
# Re-create with same barcode → should restore, not insert
resp = create_food(client, name="Restored", barcode="restore-me", calories_per_unit=200)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["id"] == fid # same row
assert data["name"] == "Restored" # updated
assert data["calories_per_unit"] == 200 # updated
assert data["deleted_at"] is None # restored
# Confirm only one row with this barcode
search = client.get("/api/foods", params={"barcode": "restore-me"})
assert len(search.json()) == 1
def test_multiple_none_barcodes_allowed(client):
@@ -322,3 +334,101 @@ def test_search_excludes_deleted_by_default(client):
resp = client.get("/api/foods", params={"q": "Hidden", "include_deleted": "true"})
assert len(resp.json()) == 1
# ── GET /api/foods/recent (TICKET-006) ──────────────────────────────────────
def test_recent_empty_when_no_logs(client):
"""With no daily_log entries, recent returns [] (no crash)."""
resp = client.get("/api/foods/recent")
assert resp.status_code == 200
assert resp.json() == []
def test_recent_ordering_by_last_logged(client):
"""Foods ordered by the daily_log entry's created_at (most recent log
action), NOT by the food's own created_at."""
# Create two foods with unique names
a = create_food(client, name="ZZ-Recent-A-Later")
b = create_food(client, name="ZZ-Recent-B-First")
a_id = a.json()["id"]
b_id = b.json()["id"]
# Log B first, then A — so A is most recently logged
from datetime import date
client.post("/api/log", json={"food_id": b_id, "quantity": 1, "date": str(date.today())})
client.post("/api/log", json={"food_id": a_id, "quantity": 1, "date": str(date.today())})
resp = client.get("/api/foods/recent")
assert resp.status_code == 200
results = resp.json()
# Filter to just our two foods (other tests may have logged other foods)
ours = [r for r in results if r["id"] in (a_id, b_id)]
assert len(ours) == 2
# A should be first (most recently logged)
assert ours[0]["id"] == a_id
assert ours[1]["id"] == b_id
def test_recent_deduplicates(client):
"""A food logged multiple times appears only once in recent."""
from datetime import date
food = create_food(client, name="ZZ-Multi-log")
fid = food.json()["id"]
# Log the same food twice
client.post("/api/log", json={"food_id": fid, "quantity": 1, "date": str(date.today())})
client.post("/api/log", json={"food_id": fid, "quantity": 2, "date": str(date.today())})
resp = client.get("/api/foods/recent")
results = resp.json()
# Should appear only once
ids = [r["id"] for r in results]
assert ids.count(fid) == 1
def test_recent_excludes_deleted(client):
"""Soft-deleted foods must not appear in recent."""
from datetime import date
food = create_food(client, name="ZZ-Delete-Me-Soon")
fid = food.json()["id"]
# Log it once
client.post("/api/log", json={"food_id": fid, "quantity": 1, "date": str(date.today())})
# Soft-delete
client.delete(f"/api/foods/{fid}")
resp = client.get("/api/foods/recent")
results = resp.json()
assert not any(r["id"] == fid for r in results)
def test_recent_respects_limit(client):
"""limit query param caps the result count."""
from datetime import date
today = str(date.today())
# Create 5 foods and log them
ids = []
for i in range(5):
f = create_food(client, name=f"ZZ-LimitTest {i}")
fid = f.json()["id"]
ids.append(fid)
client.post("/api/log", json={"food_id": fid, "quantity": 1, "date": today})
# Custom limit of 2 — our 5 foods are the most recent, but limit caps it
resp = client.get("/api/foods/recent?limit=2")
assert len(resp.json()) == 2
# All 5 should be within the default limit (10)
resp = client.get("/api/foods/recent")
ours = [r for r in resp.json() if r["id"] in ids]
assert len(ours) == 5
def test_recent_limit_enforced(client):
"""limit > 50 should be rejected."""
resp = client.get("/api/foods/recent?limit=100")
assert resp.status_code == 422
+535
View File
@@ -0,0 +1,535 @@
"""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