diff --git a/backend/routers/foods.py b/backend/routers/foods.py index a8138ec..e469f8c 100644 --- a/backend/routers/foods.py +++ b/backend/routers/foods.py @@ -12,6 +12,7 @@ from services.foods import ( get_food, get_recent_foods, list_foods, + restore_food, update_food, ) @@ -75,6 +76,16 @@ def _update_food(food_id: int, data: FoodUpdate, db: Session = Depends(get_db)): return result +@router.post("/{food_id}/restore", response_model=FoodRead) +def _restore_food(food_id: int, db: Session = Depends(get_db)): + """Restore a soft-deleted food: clears deleted_at (spec §3.1). + Idempotent — restoring a live food returns it unchanged.""" + result = restore_food(db, food_id) + if result is None: + raise HTTPException(status_code=404, detail="Food not found") + return result + + @router.delete("/{food_id}", response_model=FoodRead) def _delete_food(food_id: int, db: Session = Depends(get_db)): """Soft-delete: sets deleted_at. Food hidden from search, still visible by id.""" diff --git a/backend/services/foods.py b/backend/services/foods.py index 057705c..fc20344 100644 --- a/backend/services/foods.py +++ b/backend/services/foods.py @@ -165,6 +165,21 @@ def delete_food(db: Session, food_id: int) -> FoodRead | None: return FoodRead.model_validate(food) +def restore_food(db: Session, food_id: int) -> FoodRead | None: + """Restore a soft-deleted food: clears deleted_at (spec §3.1). + Idempotent — restoring a live food just returns it. Returns None if not found.""" + food = db.get(Food, food_id) + if food is None: + return None + + if food.deleted_at is not None: + food.deleted_at = None + food.updated_at = datetime.now(timezone.utc).replace(tzinfo=None) + db.commit() + db.refresh(food) + return FoodRead.model_validate(food) + + # ── Recent foods (TICKET-006) ──────────────────────────────────────────────── diff --git a/backend/tests/test_foods.py b/backend/tests/test_foods.py index 0583ae9..c9d447a 100644 --- a/backend/tests/test_foods.py +++ b/backend/tests/test_foods.py @@ -432,3 +432,84 @@ 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