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:
@@ -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,
|
||||
|
||||
+50
-13
@@ -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,
|
||||
result = fetch_product(barcode)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Product not found for barcode: {barcode}"
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
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
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user