TICKET-008 (backend): POST /api/foods/{id}/restore — idempotent, history intact
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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) ────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user