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:
+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
|
||||
|
||||
Reference in New Issue
Block a user