Files
calcount/backend/tests/test_log.py
T
Craig 87d7eca468 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)
2026-07-26 13:15:46 +01:00

378 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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