32461b7405
- 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)
435 lines
14 KiB
Python
435 lines
14 KiB
Python
"""Foods CRUD tests — TICKET-001 (spec §3.1, §8.1 rules 7, 11)."""
|
|
|
|
import time
|
|
|
|
|
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def create_food(client, **overrides) -> dict:
|
|
"""Create a food via POST and return the response JSON."""
|
|
payload = {
|
|
"name": "Test Food",
|
|
"brand": "Test Brand",
|
|
"calories_per_unit": 250.0,
|
|
"source": "manual",
|
|
"unit_type": "weight",
|
|
}
|
|
payload.update(overrides)
|
|
resp = client.post("/api/foods", json=payload)
|
|
return resp
|
|
|
|
|
|
def assert_422(resp):
|
|
assert resp.status_code == 422, f"expected 422, got {resp.status_code}: {resp.text}"
|
|
|
|
|
|
# ── Create + read round-trip ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_create_and_read(client):
|
|
"""POST creates a food (201), GET/{id} returns it."""
|
|
resp = create_food(client)
|
|
assert resp.status_code == 201, resp.text
|
|
data = resp.json()
|
|
|
|
assert data["name"] == "Test Food"
|
|
assert data["brand"] == "Test Brand"
|
|
assert data["calories_per_unit"] == 250.0
|
|
assert data["source"] == "manual"
|
|
assert data["unit_type"] == "weight"
|
|
assert data["is_meal"] is False
|
|
assert data["deleted_at"] is None
|
|
assert "id" in data
|
|
assert "created_at" in data
|
|
assert "updated_at" in data
|
|
|
|
fid = data["id"]
|
|
resp2 = client.get(f"/api/foods/{fid}")
|
|
assert resp2.status_code == 200
|
|
assert resp2.json() == data
|
|
|
|
|
|
def test_get_food_404(client):
|
|
resp = client.get("/api/foods/99999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── Validation failures (§8.1 rule 11) ───────────────────────────────────────
|
|
|
|
|
|
def test_missing_calories_for_non_meal(client):
|
|
"""calories_per_unit is required when is_meal=false."""
|
|
resp = create_food(client, calories_per_unit=None)
|
|
assert_422(resp)
|
|
|
|
|
|
def test_negative_calories(client):
|
|
resp = create_food(client, calories_per_unit=-5)
|
|
assert_422(resp)
|
|
|
|
|
|
def test_zero_calories(client):
|
|
resp = create_food(client, calories_per_unit=0)
|
|
assert_422(resp)
|
|
|
|
|
|
def test_bad_unit_type(client):
|
|
resp = create_food(client, unit_type="volume")
|
|
assert_422(resp)
|
|
|
|
|
|
def test_bad_source(client):
|
|
resp = create_food(client, source="unknown")
|
|
assert_422(resp)
|
|
|
|
|
|
def test_negative_serving_size(client):
|
|
resp = create_food(client, serving_size_g=-10)
|
|
assert_422(resp)
|
|
|
|
|
|
def test_negative_protein(client):
|
|
resp = create_food(client, protein_per_unit=-1)
|
|
assert_422(resp)
|
|
|
|
|
|
def test_negative_carbs(client):
|
|
resp = create_food(client, carbs_per_unit=-1)
|
|
assert_422(resp)
|
|
|
|
|
|
def test_negative_fat(client):
|
|
resp = create_food(client, fat_per_unit=-1)
|
|
assert_422(resp)
|
|
|
|
|
|
def test_missing_name(client):
|
|
resp = client.post("/api/foods", json={"calories_per_unit": 100})
|
|
assert_422(resp)
|
|
|
|
|
|
# ── Soft-delete ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_soft_delete_hidden_from_search(client):
|
|
"""Deleted foods do not appear in GET /api/foods, but GET by id still works."""
|
|
resp = create_food(client, name="Delete Me")
|
|
fid = resp.json()["id"]
|
|
|
|
# Confirm it appears in search
|
|
resp = client.get("/api/foods")
|
|
assert any(f["id"] == fid for f in resp.json())
|
|
|
|
# Soft-delete
|
|
resp = client.delete(f"/api/foods/{fid}")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["id"] == fid
|
|
assert data["deleted_at"] is not None
|
|
|
|
# Hidden from search by default
|
|
resp = client.get("/api/foods")
|
|
assert not any(f["id"] == fid for f in resp.json())
|
|
|
|
# Still visible by id (for historical log rendering)
|
|
resp = client.get(f"/api/foods/{fid}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["deleted_at"] is not None
|
|
|
|
|
|
def test_delete_404(client):
|
|
resp = client.delete("/api/foods/99999")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
def test_include_deleted_shows_deleted(client):
|
|
"""include_deleted=true returns deleted foods in search."""
|
|
resp = create_food(client, name="DeletedVisible")
|
|
fid = resp.json()["id"]
|
|
|
|
client.delete(f"/api/foods/{fid}")
|
|
|
|
resp = client.get("/api/foods", params={"include_deleted": "true"})
|
|
assert any(f["id"] == fid for f in resp.json())
|
|
|
|
|
|
# ── Barcode uniqueness ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_barcode_conflict_on_live_food(client):
|
|
"""Creating a food with a barcode that exists on a LIVE food → 409."""
|
|
create_food(client, name="First", barcode="1234567890")
|
|
resp = create_food(client, name="Second", barcode="1234567890")
|
|
assert resp.status_code == 409, resp.text
|
|
assert "barcode" in resp.json()["detail"].lower()
|
|
|
|
|
|
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}")
|
|
|
|
# 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):
|
|
"""Multiple foods with no barcode (NULL) are fine."""
|
|
create_food(client, name="A", barcode=None)
|
|
resp = create_food(client, name="B", barcode=None)
|
|
assert resp.status_code == 201
|
|
|
|
|
|
# ── Update ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_update_bumps_updated_at(client):
|
|
"""PUT updates editable fields and bumps updated_at."""
|
|
resp = create_food(client, name="Original")
|
|
fid = resp.json()["id"]
|
|
original_updated = resp.json()["updated_at"]
|
|
|
|
# Small delay so the timestamp is visibly different
|
|
time.sleep(0.1) if "sqlite" not in str(client.base_url) else None
|
|
|
|
resp = client.put(f"/api/foods/{fid}", json={"name": "Updated"})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["name"] == "Updated"
|
|
assert data["updated_at"] != original_updated
|
|
|
|
|
|
def test_update_all_editable_fields(client):
|
|
"""PUT updates name, brand, serving info, nutrition."""
|
|
resp = create_food(client, name="Original")
|
|
fid = resp.json()["id"]
|
|
|
|
payload = {
|
|
"name": "New Name",
|
|
"brand": "New Brand",
|
|
"calories_per_unit": 300.0,
|
|
"protein_per_unit": 10.0,
|
|
"carbs_per_unit": 20.0,
|
|
"fat_per_unit": 5.0,
|
|
"serving_size_g": 100.0,
|
|
"serving_name": "1 scoop",
|
|
}
|
|
resp = client.put(f"/api/foods/{fid}", json=payload)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
for key, val in payload.items():
|
|
assert data[key] == val, f"field {key} expected {val}, got {data[key]}"
|
|
|
|
|
|
def test_update_partial(client):
|
|
"""PUT with partial data only changes the supplied fields."""
|
|
resp = create_food(client, name="Original", brand="Old Brand")
|
|
fid = resp.json()["id"]
|
|
original = resp.json()
|
|
|
|
resp = client.put(f"/api/foods/{fid}", json={"name": "New Name"})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["name"] == "New Name"
|
|
assert data["brand"] == "Old Brand" # unchanged
|
|
assert data["calories_per_unit"] == original["calories_per_unit"]
|
|
|
|
|
|
def test_update_404(client):
|
|
resp = client.put("/api/foods/99999", json={"name": "Nope"})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# ── Search / list ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_search_by_name(client):
|
|
create_food(client, name="Chicken Breast")
|
|
create_food(client, name="Beef Steak")
|
|
|
|
resp = client.get("/api/foods", params={"q": "chicken"})
|
|
assert resp.status_code == 200
|
|
results = resp.json()
|
|
assert len(results) == 1
|
|
assert results[0]["name"] == "Chicken Breast"
|
|
|
|
|
|
def test_search_by_brand(client):
|
|
create_food(client, name="Chips", brand="Lays")
|
|
create_food(client, name="Cookies", brand="Oreo")
|
|
|
|
resp = client.get("/api/foods", params={"q": "oreo"})
|
|
assert resp.status_code == 200
|
|
results = resp.json()
|
|
assert len(results) == 1
|
|
assert results[0]["brand"] == "Oreo"
|
|
|
|
|
|
def test_search_by_barcode(client):
|
|
create_food(client, name="Scanned Item", barcode="9988776655")
|
|
|
|
resp = client.get("/api/foods", params={"barcode": "9988776655"})
|
|
assert resp.status_code == 200
|
|
results = resp.json()
|
|
assert len(results) == 1
|
|
assert results[0]["barcode"] == "9988776655"
|
|
|
|
|
|
def test_search_barcode_not_found(client):
|
|
resp = client.get("/api/foods", params={"barcode": "nonexistent"})
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
|
|
def test_pagination_defaults(client):
|
|
"""limit defaults to 50, offset to 0."""
|
|
# Count existing foods, then create 5 more and check the delta
|
|
before = len(client.get("/api/foods").json())
|
|
for i in range(5):
|
|
create_food(client, name=f"PagDefault {i}")
|
|
resp = client.get("/api/foods")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == before + 5
|
|
|
|
|
|
def test_pagination_limit_offset(client):
|
|
"""limit/offset work as expected."""
|
|
# Use a unique prefix to isolate from other tests
|
|
prefix = "PagOffTest"
|
|
for i in range(5):
|
|
create_food(client, name=f"{prefix} {i}")
|
|
|
|
def count(limit, offset):
|
|
resp = client.get("/api/foods", params={"q": prefix, "limit": limit, "offset": offset})
|
|
return len(resp.json())
|
|
|
|
assert count(2, 0) == 2
|
|
assert count(2, 2) == 2
|
|
assert count(2, 4) == 1
|
|
|
|
|
|
def test_search_excludes_deleted_by_default(client):
|
|
"""Deleted foods are not in search results unless include_deleted=true."""
|
|
create_food(client, name="Visible")
|
|
resp = create_food(client, name="Hidden")
|
|
client.delete(f"/api/foods/{resp.json()['id']}")
|
|
|
|
resp = client.get("/api/foods", params={"q": "Hidden"})
|
|
assert resp.json() == []
|
|
|
|
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
|