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)
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user