"""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 # ── Restore endpoint (TICKET-008, spec §3.1) ───────────────────────────────── def test_restore_clears_deleted_flag(client): """POST /api/foods/{id}/restore clears deleted_at on a soft-deleted food.""" resp = create_food(client, name="RestoreMe") fid = resp.json()["id"] client.delete(f"/api/foods/{fid}") resp = client.post(f"/api/foods/{fid}/restore") assert resp.status_code == 200, resp.text assert resp.json()["deleted_at"] is None # Confirm via GET resp = client.get(f"/api/foods/{fid}") assert resp.json()["deleted_at"] is None def test_restore_404_unknown_id(client): resp = client.post("/api/foods/99999/restore") assert resp.status_code == 404 def test_restore_not_deleted_is_sensible(client): """Restoring a food that isn't deleted returns it unchanged (idempotent).""" resp = create_food(client, name="NeverDeleted") fid = resp.json()["id"] resp = client.post(f"/api/foods/{fid}/restore") assert resp.status_code == 200, resp.text assert resp.json()["id"] == fid assert resp.json()["deleted_at"] is None def test_restore_reappears_in_search(client): """A restored food shows up in default search again.""" resp = create_food(client, name="RestoreSearchable") fid = resp.json()["id"] client.delete(f"/api/foods/{fid}") resp = client.get("/api/foods", params={"q": "RestoreSearchable"}) assert not any(f["id"] == fid for f in resp.json()) client.post(f"/api/foods/{fid}/restore") resp = client.get("/api/foods", params={"q": "RestoreSearchable"}) assert any(f["id"] == fid for f in resp.json()) def test_restore_preserves_history(client): """Restoring does not touch nutrition fields or created_at (history intact).""" resp = create_food(client, name="RestoreHistory", calories_per_unit=321.0) fid = resp.json()["id"] created_at = resp.json()["created_at"] client.delete(f"/api/foods/{fid}") resp = client.post(f"/api/foods/{fid}/restore") data = resp.json() assert data["calories_per_unit"] == 321.0 assert data["created_at"] == created_at def test_restore_still_visible_in_historical_log(client): """A food soft-deleted and restored still renders in old log entries (§2.1).""" resp = create_food(client, name="RestoreLog", calories_per_unit=100.0) fid = resp.json()["id"] log_resp = client.post("/api/log", json={ "food_id": fid, "quantity": 200.0, "date": "2026-07-26", }) assert log_resp.status_code == 201, log_resp.text client.delete(f"/api/foods/{fid}") client.post(f"/api/foods/{fid}/restore") resp = client.get("/api/log", params={"date": "2026-07-26"}) entries = [e for e in resp.json() if e["food_id"] == fid] assert entries, "log entry missing after delete/restore" assert entries[0]["food"]["name"] == "RestoreLog" assert entries[0]["computed_nutrition"]["calories"] == 200.0