TICKET-003: Daily log write path

- POST/PUT/DELETE /api/log, GET /api/log?date= with embedded food
- sort_order auto-appends per day; hard delete for log entries
- Soft-deleted foods rejected for new logs (404) but render in history
- Full suite green (87 passed)
This commit is contained in:
Craig
2026-07-26 13:15:46 +01:00
parent 601c0d461e
commit 87d7eca468
4 changed files with 564 additions and 12 deletions
+377
View File
@@ -0,0 +1,377 @@
"""Daily log tests — TICKET-003 (spec §2.3, §3.3, §8.1 rules 23, 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