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
+12
View File
@@ -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,
+51 -14
View File
@@ -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)
+83 -26
View File
@@ -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 ───────────────────────────────────────────────────────────────────
+266
View File
@@ -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)
+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