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
+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