"""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