Files
calcount/backend/tests/test_foods.py
T
Craig 3f2c765c84 TICKET-001: Foods CRUD with soft-delete
- POST/GET/PUT/DELETE /api/foods per spec 3.1 (minus restore)
- Service layer (services/foods.py) with shared soft-delete query helper
- FoodCreate extended, FoodUpdate/FoodRead schemas added
- Barcode conflict returns 409; deleted foods hidden from search,
  visible by id and via include_deleted=true
- 29 new tests, full suite green (36 passed)
2026-07-26 12:56:59 +01:00

325 lines
10 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_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")
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
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