From 32461b7405fb1dad8d6266a05e88052d1e9613ce Mon Sep 17 00:00:00 2001 From: Craig Date: Sun, 26 Jul 2026 15:46:24 +0100 Subject: [PATCH] TICKET-006 (backend): OFF normalization, scan/search/refresh, restore-on-rescan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/routers/foods.py | 12 + backend/routers/off.py | 65 ++++- backend/services/foods.py | 109 ++++++-- backend/services/off.py | 266 ++++++++++++++++++ backend/tests/conftest.py | 32 +++ backend/tests/test_foods.py | 122 +++++++- backend/tests/test_off.py | 535 ++++++++++++++++++++++++++++++++++++ 7 files changed, 1095 insertions(+), 46 deletions(-) create mode 100644 backend/services/off.py create mode 100644 backend/tests/test_off.py diff --git a/backend/routers/foods.py b/backend/routers/foods.py index da1b246..b6d8445 100644 --- a/backend/routers/foods.py +++ b/backend/routers/foods.py @@ -10,6 +10,7 @@ from services.foods import ( create_food, delete_food, get_food, + get_recent_foods, list_foods, update_food, ) @@ -17,6 +18,17 @@ from services.foods import ( router = APIRouter(prefix="/api/foods", tags=["foods"]) +@router.get("/recent", response_model=list[FoodRead]) +def _recent_foods( + limit: int = Query(default=10, le=50), + db: Session = Depends(get_db), +): + """Foods ordered by most recent appearance in daily_log, deduplicated. + Soft-deleted foods excluded. Ordered by most-recently-LOGGED, not by + foods.created_at.""" + return get_recent_foods(db, limit=limit) + + @router.get("", response_model=list[FoodRead]) def _list_foods( q: str | None = None, diff --git a/backend/routers/off.py b/backend/routers/off.py index 9eff7f3..2ba4093 100644 --- a/backend/routers/off.py +++ b/backend/routers/off.py @@ -3,28 +3,65 @@ OFF etiquette (spec §8.1 rule 10): descriptive User-Agent, timeouts, and kcal-vs-kJ normalization in exactly one module. Tests mock at the httpx boundary — never hit the real OFF API (spec §8.4). + +Routes: + GET /api/off/product/{barcode} — lookup + normalize; 404 if not found + GET /api/off/search?q= — search + normalize each hit; [] if none + POST /api/off/refresh/{food_id} — re-fetch by stored barcode, update row """ -import httpx -from fastapi import APIRouter +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session -OFF_BASE_URL = "https://world.openfoodfacts.org" -OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)" -OFF_TIMEOUT = 10.0 +from database import get_db +from services.off import ( + NoBarcodeError, + RefreshError, + fetch_product, + refresh_food, + search_off, +) router = APIRouter(prefix="/api/off", tags=["off"]) @router.get("/product/{barcode}") def get_product(barcode: str): - """Proxy a product lookup by barcode. Returns raw OFF JSON for now. + """Look up a product by barcode on OpenFoodFacts and return normalized + data matching our FoodCreate shape. - TODO: normalize to our foods schema (spec §3.5) in one shared module. + Returns 404 when OFF has status=0, no product, or the product is + unusable (no name and no computable calories). """ - resp = httpx.get( - f"{OFF_BASE_URL}/api/v2/product/{barcode}", - headers={"User-Agent": OFF_USER_AGENT}, - timeout=OFF_TIMEOUT, - ) - resp.raise_for_status() - return resp.json() + result = fetch_product(barcode) + if result is None: + raise HTTPException( + status_code=404, detail=f"Product not found for barcode: {barcode}" + ) + return result + + +@router.get("/search") +def get_search(q: str = Query(..., min_length=1, description="Search query")): + """Search OpenFoodFacts by text query. Returns a list of normalized + food dicts (matching our FoodCreate shape). Empty list when nothing + is found or all results are unusable. + """ + return search_off(q) + + +@router.post("/refresh/{food_id}") +def post_refresh(food_id: int, db: Session = Depends(get_db)): + """Re-fetch a food's data from OpenFoodFacts by its stored barcode, + normalize, and update the local row (nutrition, name, brand, serving, + off_data; bumps updated_at). + + 404 — food_id not found, or OFF no longer has the product. + 400 — the food has no barcode (can't refresh). + """ + try: + return refresh_food(db, food_id) + except RefreshError as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) + except NoBarcodeError as e: + raise HTTPException(status_code=e.status_code, detail=e.detail) diff --git a/backend/services/foods.py b/backend/services/foods.py index 054239b..b750b6e 100644 --- a/backend/services/foods.py +++ b/backend/services/foods.py @@ -6,10 +6,10 @@ Handlers stay thin (~15 lines) by calling into these functions. from datetime import datetime, timezone -from sqlalchemy import select +from sqlalchemy import desc, func, select from sqlalchemy.orm import Session -from models import Food +from models import DailyLogEntry, Food from schemas import FoodCreate, FoodRead, FoodUpdate @@ -66,38 +66,36 @@ def get_food(db: Session, food_id: int) -> FoodRead | None: def create_food(db: Session, data: FoodCreate) -> FoodRead: - """Create a food. Checks barcode uniqueness on live foods (409). + """Create a food. Barcode uniqueness on LIVE foods → 409. + + Restore-on-rescan (§3.1): when the barcode matches a SOFT-DELETED food + (deleted_at IS NOT NULL), clear deleted_at and UPDATE that existing row + with the new data instead of inserting a duplicate. All in one transaction. + SQLite treats NULL barcodes as distinct, so multiple barcode-less foods - are fine.""" + are fine. + """ if data.barcode is not None: existing = db.scalar( select(Food).where(Food.barcode == data.barcode) ) if existing is not None: + if existing.deleted_at is not None: + # ── Restore-on-rescan: update the soft-deleted row ── + _apply_create_data(existing, data) + existing.deleted_at = None + existing.updated_at = _now() + db.commit() + db.refresh(existing) + return FoodRead.model_validate(existing) + # Live food with same barcode → conflict raise BarcodeConflictError(data.barcode) - now = datetime.now(timezone.utc).replace(tzinfo=None) - food = Food( - name=data.name, - brand=data.brand, - barcode=data.barcode, - source=data.source, - is_meal=data.is_meal, - unit_type=data.unit_type, - calories_per_unit=data.calories_per_unit, - protein_per_unit=data.protein_per_unit, - carbs_per_unit=data.carbs_per_unit, - fat_per_unit=data.fat_per_unit, - fiber_per_unit=data.fiber_per_unit, - saturated_fat_per_unit=data.saturated_fat_per_unit, - sugars_per_unit=data.sugars_per_unit, - sodium_per_unit=data.sodium_per_unit, - serving_size_g=data.serving_size_g, - serving_name=data.serving_name, - off_data=data.off_data, - created_at=now, - updated_at=now, - ) + now = _now() + food = Food() + _apply_create_data(food, data) + food.created_at = now + food.updated_at = now db.add(food) db.commit() db.refresh(food) @@ -144,6 +142,65 @@ def delete_food(db: Session, food_id: int) -> FoodRead | None: return FoodRead.model_validate(food) +# ── Recent foods (TICKET-006) ──────────────────────────────────────────────── + + +def get_recent_foods(db: Session, limit: int = 10) -> list[FoodRead]: + """Return foods ordered by most recent appearance in daily_log. + + Deduplicated: each food appears at most once. + Excludes soft-deleted foods (§8.1 rule 7). + + Ordered by the daily_log entry's created_at (the moment the food was + logged), NOT by the food's own created_at and NOT by the log ``date`` + field. This means a food you just backfilled to an old date still + appears as "recent" because your log *action* was recent — which is + the intended UX. + + If no foods have ever been logged, returns an empty list. + """ + last_logged = func.max(DailyLogEntry.created_at).label("last_logged") + stmt = ( + select(Food, last_logged) + .join(DailyLogEntry, DailyLogEntry.food_id == Food.id) + .where(_not_deleted()) + .group_by(Food.id) + .order_by(desc("last_logged")) + .limit(limit) + ) + rows = db.execute(stmt).all() + return [FoodRead.model_validate(row[0]) for row in rows] + + +# ── Internal helpers ───────────────────────────────────────────────────────── + + +def _now() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _apply_create_data(food: Food, data: FoodCreate) -> None: + """Copy FoodCreate fields onto a Food ORM object (used for both insert + and restore-on-rescan update paths).""" + food.name = data.name + food.brand = data.brand + food.barcode = data.barcode + food.source = data.source + food.is_meal = data.is_meal + food.unit_type = data.unit_type + food.calories_per_unit = data.calories_per_unit + food.protein_per_unit = data.protein_per_unit + food.carbs_per_unit = data.carbs_per_unit + food.fat_per_unit = data.fat_per_unit + food.fiber_per_unit = data.fiber_per_unit + food.saturated_fat_per_unit = data.saturated_fat_per_unit + food.sugars_per_unit = data.sugars_per_unit + food.sodium_per_unit = data.sodium_per_unit + food.serving_size_g = data.serving_size_g + food.serving_name = data.serving_name + food.off_data = data.off_data + + # ── Errors ─────────────────────────────────────────────────────────────────── diff --git a/backend/services/off.py b/backend/services/off.py new file mode 100644 index 0000000..db2aa01 --- /dev/null +++ b/backend/services/off.py @@ -0,0 +1,266 @@ +"""OFF normalization + HTTP calls (spec §8.1 rule 10). + +All OFF→foods field mapping lives here and nowhere else. +Tests mock at the httpx boundary — never hit the real OFF API (spec §8.4). + +Normalization field mapping (OFF v2 → FoodCreate): + - name: product_name (fallback: generic_name) + - brand: brands (comma-separated → first entry trimmed; see _first_brand) + - barcode: code + - source: "openfoodfacts" + - unit_type: "weight" (OFF nutrition is per-100g) + - calories_per_unit: nutriments["energy-kcal_100g"], or + nutriments["energy-kj_100g"] / 4.184 if kcal absent → rounded 1dp + - *_per_unit: nutriments["{field}_100g"] (absent → null) + - serving_size_g: serving_quantity parsed as float (grams) if present + - serving_name: serving_size (human string) if present + - off_data: raw product JSON serialized to string + +Not-found rule: returns None when the product has no usable name AND no +computable calories (both missing → not a useful food). +""" + +import json +from datetime import datetime, timezone + +import httpx +from sqlalchemy.orm import Session + +from models import Food +from schemas import FoodRead + +OFF_BASE_URL = "https://world.openfoodfacts.org" +OFF_USER_AGENT = "CalCount/0.1 (personal self-hosted calorie counter)" +OFF_TIMEOUT = 10.0 + +# 1 kcal = 4.184 kJ → kJ_to_kcal = 1 / 4.184 +_KJ_TO_KCAL = 1.0 / 4.184 + + +# ── Client factory (mocked at the httpx boundary in tests) ─────────────────── + + +def _default_client() -> httpx.Client: + """Return an httpx Client with our User-Agent and timeout.""" + return httpx.Client( + headers={"User-Agent": OFF_USER_AGENT}, + timeout=OFF_TIMEOUT, + ) + + +# ── Normalization (the ONE module — spec §8.1 rule 10) ────────────────────── + + +def _first_brand(brands_raw: str | None) -> str | None: + """OFF brands is comma-separated (e.g. "Nutella, Ferrero, Yum yum"). + We split on comma, trim whitespace, and return the first non-empty entry. + Returns None when the string is empty or all-whitespace.""" + if not brands_raw or not brands_raw.strip(): + return None + parts = [p.strip() for p in brands_raw.split(",")] + for p in parts: + if p: + return p + return None + + +def _parse_float(value: object) -> float | None: + """Coerce an OFF value (number or string) to float. Returns None on failure.""" + if value is None: + return None + try: + return float(value) + except (ValueError, TypeError): + return None + + +def normalize_off_product(product: dict) -> dict | None: + """Map an OFF v2 product JSON object → dict matching FoodCreate. + + Returns None when the product is unusable: no name AND no computable + calories. This is the "not-found" signal for the proxy layer. + """ + nutriments = product.get("nutriments") or {} + + # ── name ── + name = (product.get("product_name") or product.get("generic_name") or "").strip() + + # ── calories: prefer kcal; fall back to kJ → kcal conversion ── + kcal = _parse_float(nutriments.get("energy-kcal_100g")) + if kcal is None: + kj = _parse_float(nutriments.get("energy-kj_100g")) + if kj is not None: + kcal = round(kj * _KJ_TO_KCAL, 1) + + # ── not-found check ── + if not name and kcal is None: + return None + + # ── brand ── + brand = _first_brand(product.get("brands")) + + # ── serving ── + serving_qty = _parse_float(product.get("serving_quantity")) + + return { + "name": name, + "brand": brand, + "barcode": str(product.get("code", "")), + "source": "openfoodfacts", + "is_meal": False, + "unit_type": "weight", + "calories_per_unit": kcal, + "protein_per_unit": _parse_float(nutriments.get("proteins_100g")), + "carbs_per_unit": _parse_float(nutriments.get("carbohydrates_100g")), + "fat_per_unit": _parse_float(nutriments.get("fat_100g")), + "fiber_per_unit": _parse_float(nutriments.get("fiber_100g")), + "saturated_fat_per_unit": _parse_float(nutriments.get("saturated-fat_100g")), + "sugars_per_unit": _parse_float(nutriments.get("sugars_100g")), + "sodium_per_unit": _parse_float(nutriments.get("sodium_100g")), + "serving_size_g": serving_qty, + "serving_name": product.get("serving_size") or None, + "off_data": json.dumps(product), + } + + +# ── OFF proxy calls ────────────────────────────────────────────────────────── + + +def fetch_product(barcode: str, client: httpx.Client | None = None) -> dict | None: + """Fetch a product from OFF by barcode and return normalized data. + + Returns the normalized food dict (FoodCreate shape), or None if OFF + has no product / status ≠ 1 / unusable data. + + Upstream errors (HTTP 5xx, timeouts, connection failures) are treated + as "not found" (returns None → router maps to 404) so the API degrades + gracefully instead of crashing with a 500. + + Pass *client* with a MockTransport in tests; otherwise a default + httpx.Client is created (and closed) per call. + """ + own = client is None + if own: + client = _default_client() + try: + resp = client.get(f"{OFF_BASE_URL}/api/v2/product/{barcode}") + resp.raise_for_status() + data = resp.json() + if data.get("status") != 1 or not data.get("product"): + return None + return normalize_off_product(data["product"]) + except (httpx.HTTPStatusError, httpx.RequestError): + # Upstream unavailable or transport failure — degrade gracefully. + return None + finally: + if own: + client.close() + + +def search_off(query: str, client: httpx.Client | None = None) -> list[dict]: + """Search OFF by text query and return a list of normalized food dicts. + + Fields requested: code, product_name, generic_name, brands, nutriments, + serving_quantity, serving_size. Page size is capped at 20. + + Returns an empty list when OFF has no matches, all results are + unusable after normalization, or an upstream/transport error occurs + (graceful degradation — no 500s). + """ + own = client is None + if own: + client = _default_client() + try: + resp = client.get( + f"{OFF_BASE_URL}/api/v2/search", + params={ + "search_terms": query, + "fields": "code,product_name,generic_name,brands," + "nutriments,serving_quantity,serving_size", + "page_size": 20, + }, + ) + resp.raise_for_status() + data = resp.json() + results: list[dict] = [] + for p in data.get("products", []): + norm = normalize_off_product(p) + if norm is not None: + results.append(norm) + return results + except (httpx.HTTPStatusError, httpx.RequestError): + # Upstream unavailable or transport failure — degrade gracefully. + return [] + finally: + if own: + client.close() + + +# ── Refresh (§3.5) ─────────────────────────────────────────────────────────── + + +def _now() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +class RefreshError(Exception): + """Errors from the refresh endpoint that map to HTTP status codes.""" + + def __init__(self, status_code: int, detail: str): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + +class NoBarcodeError(RefreshError): + """Food has no barcode — can't refresh from OFF.""" + + def __init__(self, food_id: int): + super().__init__( + 400, + f"Food {food_id} has no barcode — cannot refresh from OpenFoodFacts", + ) + + +def refresh_food( + db: Session, food_id: int, client: httpx.Client | None = None +) -> FoodRead: + """Re-fetch a food's data from OFF by its stored barcode and update the + local row (nutrition, name, brand, serving, off_data; bump updated_at). + + Raises: + RefreshError(404) — food_id not found + NoBarcodeError(400) — food has no barcode + RefreshError(404) — OFF no longer has the product + """ + food = db.get(Food, food_id) + if food is None: + raise RefreshError(404, f"Food {food_id} not found") + + barcode = food.barcode + if not barcode: + raise NoBarcodeError(food_id) + + norm = fetch_product(barcode, client=client) + if norm is None: + raise RefreshError(404, f"Barcode '{barcode}' no longer found on OpenFoodFacts") + + # Update the local row with fresh OFF data + food.name = norm["name"] + food.brand = norm["brand"] + food.calories_per_unit = norm["calories_per_unit"] + food.protein_per_unit = norm["protein_per_unit"] + food.carbs_per_unit = norm["carbs_per_unit"] + food.fat_per_unit = norm["fat_per_unit"] + food.fiber_per_unit = norm["fiber_per_unit"] + food.saturated_fat_per_unit = norm["saturated_fat_per_unit"] + food.sugars_per_unit = norm["sugars_per_unit"] + food.sodium_per_unit = norm["sodium_per_unit"] + food.serving_size_g = norm["serving_size_g"] + food.serving_name = norm["serving_name"] + food.off_data = norm["off_data"] + food.updated_at = _now() + + db.commit() + db.refresh(food) + return FoodRead.model_validate(food) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4347d6f..c91762a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_foods.py b/backend/tests/test_foods.py index ae0151d..0583ae9 100644 --- a/backend/tests/test_foods.py +++ b/backend/tests/test_foods.py @@ -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 diff --git a/backend/tests/test_off.py b/backend/tests/test_off.py new file mode 100644 index 0000000..cabf40c --- /dev/null +++ b/backend/tests/test_off.py @@ -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="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