"""Daily log tests — TICKET-003 (spec §2.3, §3.3, §8.1 rules 2–3, 6, 8, 11). Behaviour notes: - POST /api/log rejects unknown or soft-deleted food_id with 404 (you can't log what you can't search). - sort_order for new entries defaults to end of day (max(sort_order) + 1). - GET /api/log?date= returns entries ordered by sort_order, then id, with embedded food data (including soft-deleted foods for history). - LogEntryUpdate uses exclude_unset: only supplied fields change; send {"meal_slot": null} to clear the slot. """ # ── 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) assert resp.status_code == 201, resp.text return resp.json() def _post_log(client, **overrides) -> dict: """Create a log entry, returning the full response (for status checks).""" resp = client.post("/api/log", json=overrides) return resp def _create_entry(client, **overrides): """Create a log entry and return the parsed JSON (asserts 201).""" food = _create_food(client) payload = { "food_id": food["id"], "quantity": 100.0, "date": "2025-06-15", } payload.update(overrides) resp = client.post("/api/log", json=payload) assert resp.status_code == 201, resp.text return resp.json() 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_round_trip(client): """POST creates (201), GET returns it with embedded food.""" entry = _create_entry(client) assert entry["quantity"] == 100.0 assert entry["date"] == "2025-06-15" assert entry["meal_slot"] is None assert "id" in entry assert "sort_order" in entry # Embedded food food = entry["food"] assert food["name"] == "Test Food" assert food["brand"] == "Test Brand" assert food["unit_type"] == "weight" assert food["is_meal"] is False assert food["deleted_at"] is None # GET confirms resp = client.get("/api/log", params={"date": "2025-06-15"}) assert resp.status_code == 200 entries = resp.json() assert len(entries) == 1 assert entries[0] == entry # ── meal_slot ──────────────────────────────────────────────────────────────── def test_create_with_each_meal_slot(client): """All four meal slots are accepted.""" for slot in ("breakfast", "lunch", "dinner", "snack"): entry = _create_entry(client, meal_slot=slot) assert entry["meal_slot"] == slot def test_create_without_meal_slot_defaults_null(client): """meal_slot is optional, defaults to None.""" entry = _create_entry(client) assert entry["meal_slot"] is None # ── Validation: food_id ────────────────────────────────────────────────────── def test_unknown_food_id_returns_404(client): """Logging a non-existent food_id → 404.""" resp = _post_log(client, food_id=99999, quantity=100.0, date="2025-06-15") assert resp.status_code == 404 def test_soft_deleted_food_rejected_404(client): """Logging a soft-deleted food → 404 (you can't log what you can't search).""" food = _create_food(client, name="Delete Me") fid = food["id"] client.delete(f"/api/foods/{fid}") resp = _post_log(client, food_id=fid, quantity=100.0, date="2025-06-15") assert resp.status_code == 404 # ── Validation: quantity ───────────────────────────────────────────────────── def test_negative_quantity_rejected(client): resp = _post_log(client, food_id=1, quantity=-5.0, date="2025-06-15") assert_422(resp) def test_zero_quantity_rejected(client): resp = _post_log(client, food_id=1, quantity=0.0, date="2025-06-15") assert_422(resp) # ── Validation: meal_slot ──────────────────────────────────────────────────── def test_bad_meal_slot_rejected(client): food = _create_food(client) resp = _post_log( client, food_id=food["id"], quantity=100.0, meal_slot="brunch", date="2025-06-15", ) assert_422(resp) # ── Validation: date ───────────────────────────────────────────────────────── def test_bad_date_format_rejected(client): food = _create_food(client) resp = _post_log( client, food_id=food["id"], quantity=100.0, date="06-15-2025", ) assert_422(resp) def test_missing_date_rejected(client): food = _create_food(client) resp = _post_log(client, food_id=food["id"], quantity=100.0) assert_422(resp) # ── Ordering ───────────────────────────────────────────────────────────────── def test_entries_ordered_by_sort_order_then_id(client): """GET /api/log returns entries sorted by sort_order, then id.""" food = _create_food(client) date = "2025-12-01" # unique date to avoid cross-test pollution # Create 3 entries (they'll get sort_order 1, 2, 3 automatically) eids = [] for _ in range(3): resp = _post_log(client, food_id=food["id"], quantity=100.0, date=date) assert resp.status_code == 201, resp.text eids.append(resp.json()["id"]) # Update sort_orders to reverse the creation order client.put(f"/api/log/{eids[0]}", json={"sort_order": 3}) client.put(f"/api/log/{eids[1]}", json={"sort_order": 2}) client.put(f"/api/log/{eids[2]}", json={"sort_order": 1}) resp = client.get("/api/log", params={"date": date}) entries = resp.json() ids = [e["id"] for e in entries] assert ids == [eids[2], eids[1], eids[0]] # sort_order 1, 2, 3 def test_new_entries_append_to_end(client): """New entries get increasing sort_order values (append to end of day).""" food = _create_food(client) date = "2025-12-02" # unique date to avoid cross-test pollution e1 = _create_entry(client, food_id=food["id"], date=date) e2 = _create_entry(client, food_id=food["id"], date=date) e3 = _create_entry(client, food_id=food["id"], date=date) assert e1["sort_order"] < e2["sort_order"] < e3["sort_order"] # ── Embedded food data ─────────────────────────────────────────────────────── def test_embedded_food_data_complete(client): """Log entry includes full food reference: name, brand, unit_type, serving.""" food = _create_food( client, name="Banana", brand="Chiquita", unit_type="weight", serving_size_g=118.0, serving_name="1 banana", ) fid = food["id"] entry = _create_entry(client, food_id=fid, date="2025-06-15") f = entry["food"] assert f["id"] == fid assert f["name"] == "Banana" assert f["brand"] == "Chiquita" assert f["unit_type"] == "weight" assert f["serving_size_g"] == 118.0 assert f["serving_name"] == "1 banana" assert f["is_meal"] is False assert f["deleted_at"] is None def test_soft_deleted_food_still_renders_in_history(client): """Food soft-deleted after logging still appears in GET /api/log.""" food = _create_food(client, name="Old Product") fid = food["id"] _create_entry(client, food_id=fid, date="2025-01-01") # Soft-delete the food client.delete(f"/api/foods/{fid}") # The log entry should still render it resp = client.get("/api/log", params={"date": "2025-01-01"}) entries = resp.json() assert len(entries) == 1 assert entries[0]["food"]["id"] == fid assert entries[0]["food"]["name"] == "Old Product" assert entries[0]["food"]["deleted_at"] is not None # ── Update ─────────────────────────────────────────────────────────────────── def test_update_quantity(client): entry = _create_entry(client) eid = entry["id"] resp = client.put(f"/api/log/{eid}", json={"quantity": 200.0}) assert resp.status_code == 200 assert resp.json()["quantity"] == 200.0 def test_update_meal_slot_set_and_clear(client): entry = _create_entry(client) eid = entry["id"] # Set resp = client.put(f"/api/log/{eid}", json={"meal_slot": "dinner"}) assert resp.status_code == 200 assert resp.json()["meal_slot"] == "dinner" # Clear (explicit null) resp = client.put(f"/api/log/{eid}", json={"meal_slot": None}) assert resp.status_code == 200 assert resp.json()["meal_slot"] is None def test_update_sort_order(client): entry = _create_entry(client) eid = entry["id"] resp = client.put(f"/api/log/{eid}", json={"sort_order": 42}) assert resp.status_code == 200 assert resp.json()["sort_order"] == 42 def test_update_partial_preserves_other_fields(client): """PUT with only one field leaves others unchanged.""" entry = _create_entry(client, quantity=100.0, meal_slot="lunch") eid = entry["id"] resp = client.put(f"/api/log/{eid}", json={"quantity": 150.0}) assert resp.status_code == 200 data = resp.json() assert data["quantity"] == 150.0 assert data["meal_slot"] == "lunch" # unchanged def test_update_404(client): resp = client.put("/api/log/99999", json={"quantity": 100.0}) assert resp.status_code == 404 def test_update_negative_quantity_rejected(client): entry = _create_entry(client) eid = entry["id"] resp = client.put(f"/api/log/{eid}", json={"quantity": -1.0}) assert_422(resp) def test_update_zero_quantity_rejected(client): entry = _create_entry(client) eid = entry["id"] resp = client.put(f"/api/log/{eid}", json={"quantity": 0.0}) assert_422(resp) # ── Delete ─────────────────────────────────────────────────────────────────── def test_delete_removes_entry(client): date = "2025-12-03" # unique to avoid cross-test pollution entry = _create_entry(client, date=date) eid = entry["id"] resp = client.delete(f"/api/log/{eid}") assert resp.status_code == 200 # Confirm gone resp = client.get("/api/log", params={"date": date}) assert resp.json() == [] def test_delete_404(client): resp = client.delete("/api/log/99999") assert resp.status_code == 404 # ── Meal foods ─────────────────────────────────────────────────────────────── def test_log_meal_food_accepted(client): """Logging a food with is_meal=True succeeds (explosion is TICKET-007).""" meal = _create_food( client, name="My Meal", is_meal=True, calories_per_unit=None, source="meal", ) meal_id = meal["id"] entry = _create_entry(client, food_id=meal_id, quantity=1.0, date="2025-06-15") assert entry["food"]["is_meal"] is True assert entry["food"]["name"] == "My Meal" # ── Date isolation ─────────────────────────────────────────────────────────── def test_get_log_only_returns_requested_date(client): """GET /api/log?date= isolates by date.""" food = _create_food(client) d1 = "2025-12-04" d2 = "2025-12-05" _create_entry(client, food_id=food["id"], quantity=100.0, date=d1) _create_entry(client, food_id=food["id"], quantity=200.0, date=d2) resp = client.get("/api/log", params={"date": d1}) entries = resp.json() assert len(entries) == 1 assert entries[0]["date"] == d1 assert entries[0]["quantity"] == 100.0