Files
calcount/backend/tests/test_foods.py
T
Craig 32461b7405 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)
2026-07-26 15:46:24 +01:00

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