TICKET-007 (backend): meals — from-log, unpack, components, recursive nutrition, cycle detection

This commit is contained in:
Craig
2026-07-26 17:27:15 +01:00
parent 4dd44b08d0
commit a8aed9a84f
9 changed files with 1720 additions and 73 deletions
+773
View File
@@ -0,0 +1,773 @@
"""Meal tests — TICKET-007 (spec §2.2, §3.2, §4.3, §4.4, §8.1 rule 6, §8.4).
Covers:
- Nutrition recursion: meal of components, scaling factors, nested meals,
null nutrition fields contribute 0
- Cycle detection on nutrition reads: manually-constructed cycle returns zeros
- POST /api/meals/from-log happy path + rollback on failure
- POST /api/meals/{meal_id}/unpack happy path (incl. 1.5× scaling, nested
meal flattening) + rollback on failure
- PUT /api/meals/{meal_id}/components cycle rejection (transitive + direct
self-reference) + valid non-cyclic replacement
- Summary includes real meal nutrition (no longer 0 for meals)
- GET /api/log includes nested components for meal entries
- GET /api/foods/{id} returns MealRead for meals
"""
import pytest
# ── Helpers ──────────────────────────────────────────────────────────────────
def _create_food(client, **overrides) -> dict:
"""Create a food via POST and return the response JSON."""
payload = {
"name": "Test Food",
"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, f"food create failed: {resp.text}"
return resp.json()
def _create_meal(client, name, is_meal=True, source="meal") -> dict:
"""Create a meal food (is_meal=True, calories_per_unit=None)."""
return _create_food(
client, name=name, is_meal=is_meal, calories_per_unit=None, source=source,
)
def _log_entry(client, food_id, quantity, date="2025-06-15", **overrides) -> dict:
"""Create a log entry and return the parsed JSON (asserts 201)."""
payload = {"food_id": food_id, "quantity": quantity, "date": date}
payload.update(overrides)
resp = client.post("/api/log", json=payload)
assert resp.status_code == 201, f"log create failed: {resp.text}"
return resp.json()
def _get_summary(client, date="2025-06-15") -> dict:
"""Call the summary endpoint and return parsed JSON (asserts 200)."""
resp = client.get("/api/log/summary", params={"date": date})
assert resp.status_code == 200, f"summary failed: {resp.text}"
return resp.json()
def _get_log(client, date="2025-06-15") -> list[dict]:
"""Call GET /api/log?date= and return parsed JSON (asserts 200)."""
resp = client.get("/api/log", params={"date": date})
assert resp.status_code == 200, f"log GET failed: {resp.text}"
return resp.json()
def _from_log(client, name, date, entry_ids) -> dict:
"""Call POST /api/meals/from-log and return parsed JSON (asserts 201)."""
resp = client.post(
"/api/meals/from-log",
json={"name": name, "date": date, "entry_ids": entry_ids},
)
return resp
def _unpack_meal(client, meal_id, date, entry_id=None) -> dict:
"""Call POST /api/meals/{meal_id}/unpack and return parsed JSON (asserts 200)."""
body = {"date": date}
if entry_id is not None:
body["entry_id"] = entry_id
resp = client.post(f"/api/meals/{meal_id}/unpack", json=body)
return resp
def _update_components(client, meal_id, components) -> dict:
"""Call PUT /api/meals/{meal_id}/components and return response."""
resp = client.put(
f"/api/meals/{meal_id}/components",
json={"components": components},
)
return resp
# ── Nutrition recursion (§8.1 rule 1) ────────────────────────────────────────
def test_meal_nutrition_two_components_weight_type(client):
"""A meal of 100g rice (130 kcal/100g) + 1 egg (70 kcal/count) →
one meal = 130 + 70 = 200 kcal. Logged at 1.0× → 200 kcal."""
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
# Create a meal from scratch via the service (bypassing from-log to test pure nutrition)
date = "2025-07-20"
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, egg["id"], quantity=1.0, date=date)
resp = _from_log(client, "Rice + Egg", date, _get_entry_ids(client, date))
assert resp.status_code == 201, resp.text
data = resp.json()
meal = data["meal"]
entry = data["entry"]
# The replacement entry should be quantity=1.0
assert entry["quantity"] == 1.0
# Summary should reflect real meal nutrition (200 kcal)
summary = _get_summary(client, date)
assert summary["totals"]["calories"] == 200.0
def test_meal_nutrition_scaled_1_5x(client):
"""A meal logged at 1.5× should contribute 1.5× the nutrition."""
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
date = "2025-07-21"
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, egg["id"], quantity=1.0, date=date)
# Create meal from log
resp = _from_log(client, "Scaled Meal", date, _get_entry_ids(client, date))
assert resp.status_code == 201, resp.text
meal_id = resp.json()["meal"]["id"]
# Log the meal at 1.5×
_log_entry(client, meal_id, quantity=1.5, date=date)
summary = _get_summary(client, date)
# One meal at 1.0× (200 kcal) + one at 1.5× (300 kcal) = 500 total
assert summary["totals"]["calories"] == 500.0
def test_meal_nutrition_nested_meals(client):
"""A meal containing another meal → recursion depth 2 sums correctly."""
# Component foods
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
date = "2025-07-22"
# First: create an inner meal (rice + chicken)
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, chicken["id"], quantity=200.0, date=date)
inner_resp = _from_log(client, "Inner Meal", date, _get_entry_ids(client, date))
assert inner_resp.status_code == 201
inner_meal_id = inner_resp.json()["meal"]["id"]
inner_entry_id = inner_resp.json()["entry"]["id"]
# inner meal: 100g rice (130 kcal) + 200g chicken (330 kcal) = 460 kcal
# Second: create outer meal containing inner meal + another food
# We need to update inner meal's components to prepare, or just use from-log
# with the existing inner meal log entry
apple = _create_food(client, name="Apple", calories_per_unit=52.0, unit_type="weight")
_log_entry(client, apple["id"], quantity=150.0, date=date) # 78 kcal
# Now the date has: inner_meal_entry (1.0x → 460 kcal) + apple entry (78 kcal)
all_eids = _get_entry_ids(client, date)
outer_resp = _from_log(client, "Outer Meal", date, all_eids)
assert outer_resp.status_code == 201
outer_meal_id = outer_resp.json()["meal"]["id"]
# Check summary: the outer meal at 1.0× should be 460 + 78 = 538 kcal
summary = _get_summary(client, date)
assert summary["totals"]["calories"] == 538.0
# GET the outer meal: computed_nutrition_per_meal should be 538
meal_resp = client.get(f"/api/foods/{outer_meal_id}")
assert meal_resp.status_code == 200
meal_data = meal_resp.json()
assert "components" in meal_data
assert "computed_nutrition_per_meal" in meal_data
assert meal_data["computed_nutrition_per_meal"]["calories"] == 538.0
def test_meal_nutrition_null_fields_contribute_zero(client):
"""A component with null calories_per_unit contributes 0."""
# Create a meal-type food (null per_unit) as a component
placeholder = _create_meal(client, "Placeholder")
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
date = "2025-07-23"
# Log and create meal from them
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, placeholder["id"], quantity=1.0, date=date)
resp = _from_log(client, "Mixed Meal", date, _get_entry_ids(client, date))
assert resp.status_code == 201
# Summary: only rice contributes (130 kcal), placeholder is 0
summary = _get_summary(client, date)
assert summary["totals"]["calories"] == 130.0
# ── Cycle detection on nutrition reads (§8.1 rule 1 safety net) ─────────────
def test_cycle_detection_on_nutrition_reads(client):
"""A manually-constructed cycle (bypassing the write check) returns zeros,
doesn't infinite-loop."""
from database import SessionLocal
from models import Food, MealComponent
date = "2025-07-24"
# Create two meal foods
meal_a = _create_meal(client, "Meal A")
meal_b = _create_meal(client, "Meal B")
# Manually insert cycle: A → B → A
db = SessionLocal()
try:
mc1 = MealComponent(meal_id=meal_a["id"], food_id=meal_b["id"], quantity=1.0)
mc2 = MealComponent(meal_id=meal_b["id"], food_id=meal_a["id"], quantity=1.0)
db.add(mc1)
db.add(mc2)
db.commit()
finally:
db.close()
# Log meal A
_log_entry(client, meal_a["id"], quantity=1.0, date=date)
# Summary should NOT infinite-loop; cycle returns 0
summary = _get_summary(client, date)
assert summary["totals"]["calories"] == 0.0 # cycle → zeros
# Cleanup
db = SessionLocal()
try:
db.query(MealComponent).filter(
MealComponent.meal_id.in_([meal_a["id"], meal_b["id"]])
).delete()
db.commit()
finally:
db.close()
# ── POST /api/meals/from-log happy path ──────────────────────────────────────
def test_from_log_happy_path(client):
"""Create 2 foods, log them to today, from-log with both entry_ids → 201,
meal food created, components with right quantities, original 2 entries
deleted, 1 replacement entry (quantity=1.0)."""
date = "2025-07-25"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
entry_ids = [e1["id"], e2["id"]]
resp = _from_log(client, "Lunch Meal", date, entry_ids)
assert resp.status_code == 201, resp.text
data = resp.json()
# Response shape
assert "meal" in data
assert "entry" in data
meal = data["meal"]
assert meal["is_meal"] is True
assert meal["source"] == "meal"
assert meal["name"] == "Lunch Meal"
assert meal["calories_per_unit"] is None
assert meal["protein_per_unit"] is None
entry = data["entry"]
assert entry["quantity"] == 1.0
assert entry["date"] == date
assert entry["food_id"] == meal["id"]
# Original entries are gone
log_entries = _get_log(client, date)
assert len(log_entries) == 1
assert log_entries[0]["id"] == entry["id"]
# GET the meal: should have components
meal_resp = client.get(f"/api/foods/{meal['id']}")
assert meal_resp.status_code == 200
meal_data = meal_resp.json()
assert len(meal_data["components"]) == 2
comp_food_ids = {c["food_id"] for c in meal_data["components"]}
assert comp_food_ids == {f1["id"], f2["id"]}
# Check component quantities match original log entries
for c in meal_data["components"]:
if c["food_id"] == f1["id"]:
assert c["quantity"] == 200.0
elif c["food_id"] == f2["id"]:
assert c["quantity"] == 150.0
# Summary matches sum of originals
summary = _get_summary(client, date)
# 130 × 200/100 = 260, 165 × 150/100 = 247.5 → 507.5
assert summary["totals"]["calories"] == 507.5
def test_from_log_meal_components_nested_in_log(client):
"""GET /api/log response includes meal components nested for rendering
collapsible rows (§3.3)."""
date = "2025-07-26"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
resp = _from_log(client, "Lunch Meal", date, [e1["id"], e2["id"]])
assert resp.status_code == 201
# GET /api/log should show the meal with nested components
entries = _get_log(client, date)
assert len(entries) == 1
food = entries[0]["food"]
assert food["is_meal"] is True
assert food["components"] is not None
assert len(food["components"]) == 2
# ── POST /api/meals/from-log rollback on failure ────────────────────────────
def test_from_log_rollback_on_bad_entry(client):
"""One entry_id doesn't exist → 404, nothing was written."""
date = "2025-07-27"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
# Try from-log with a non-existent entry_id
resp = _from_log(client, "Bad Meal", date, [e1["id"], 99999])
assert resp.status_code == 404, resp.text
# Original entries still exist
log_entries = _get_log(client, date)
assert len(log_entries) == 1
assert log_entries[0]["id"] == e1["id"]
# No meal food was created (search for meals with source="meal")
foods_resp = client.get("/api/foods", params={"limit": 200})
meal_foods = [f for f in foods_resp.json() if f["source"] == "meal" and f["name"] == "Bad Meal"]
assert len(meal_foods) == 0
def test_from_log_rollback_on_wrong_date(client):
"""Entry from a different date → 400, nothing was written."""
date_a = "2025-07-28"
date_b = "2025-07-29"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date_a)
# Try from-log requesting date_b but entry is on date_a
resp = _from_log(client, "Wrong Date Meal", date_b, [e1["id"]])
assert resp.status_code == 400, resp.text
# Entry still exists on date_a
log_entries = _get_log(client, date_a)
assert len(log_entries) == 1
assert log_entries[0]["id"] == e1["id"]
# ── POST /api/meals/{meal_id}/unpack happy path ─────────────────────────────
def test_unpack_happy_path(client):
"""Log a meal at 1.0×, unpack → meal entry deleted, component entries
inserted with component.quantity × 1.0. Summary unchanged."""
date = "2025-07-30"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
# Create meal
resp = _from_log(client, "Lunch", date, [e1["id"], e2["id"]])
assert resp.status_code == 201
meal_id = resp.json()["meal"]["id"]
meal_entry_id = resp.json()["entry"]["id"]
# Summary before unpack
summary_before = _get_summary(client, date)
assert summary_before["totals"]["calories"] == 507.5 # 260 + 247.5
# Unpack
unpack_resp = _unpack_meal(client, meal_id, date)
assert unpack_resp.status_code == 200, unpack_resp.text
unpack_data = unpack_resp.json()
entries = unpack_data["entries"]
assert len(entries) == 2
# Meal entry is gone
log_entries = _get_log(client, date)
log_ids = {e["id"] for e in log_entries}
assert meal_entry_id not in log_ids
assert len(log_entries) == 2
# Summary unchanged
summary_after = _get_summary(client, date)
assert summary_after["totals"]["calories"] == 507.5
def test_unpack_with_scaling_1_5x(client):
"""Log a meal at 1.5×, unpack → component entries have quantity × 1.5."""
date = "2025-07-31"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
f2 = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
e2 = _log_entry(client, f2["id"], quantity=150.0, date=date)
resp = _from_log(client, "Lunch", date, [e1["id"], e2["id"]])
assert resp.status_code == 201
meal_id = resp.json()["meal"]["id"]
meal_entry_id = resp.json()["entry"]["id"]
# Update meal entry to 1.5×
client.put(f"/api/log/{meal_entry_id}", json={"quantity": 1.5})
summary_before = _get_summary(client, date)
assert summary_before["totals"]["calories"] == 761.25 # 507.5 × 1.5
# Unpack
unpack_resp = _unpack_meal(client, meal_id, date)
assert unpack_resp.status_code == 200, unpack_resp.text
unpack_data = unpack_resp.json()
# Each component should be scaled: 200*1.5=300, 150*1.5=225
entries = unpack_data["entries"]
qty_by_food = {}
for e in entries:
qty_by_food[e["food_id"]] = e["quantity"]
assert qty_by_food.get(f1["id"]) == 300.0
assert qty_by_food.get(f2["id"]) == 225.0
# Summary unchanged
summary_after = _get_summary(client, date)
assert summary_after["totals"]["calories"] == 761.25
def test_unpack_nested_meal_flattens_to_leaves(client):
"""Unpacking a meal containing a nested meal → flattens to leaf foods
with scaling factors multiplied down the chain.
1.5× outer containing a 2-component inner meal:
Inner: 100g rice (130 kcal/100g = 130) + 1 egg (70 kcal = 70) = 200 kcal
Outer: inner at 0.5 (half portion) + apple 100g (52 kcal/100g = 52) = 100 + 52 = 152
Log at 1.5× → 228 kcal. Unpack → 4 leaf entries each ×1.5×nested-scaling.
"""
date = "2025-08-01"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
apple = _create_food(client, name="Apple", calories_per_unit=52.0, unit_type="weight")
# Step 1: Log rice + egg, create inner meal
_log_entry(client, rice["id"], quantity=100.0, date=date)
e_egg = _log_entry(client, egg["id"], quantity=1.0, date=date)
inner_resp = _from_log(client, "Inner", date, _get_entry_ids(client, date))
assert inner_resp.status_code == 201
inner_meal_id = inner_resp.json()["meal"]["id"]
inner_entry_id = inner_resp.json()["entry"]["id"]
# Step 2: Update inner entry to 0.5× (half portion)
client.put(f"/api/log/{inner_entry_id}", json={"quantity": 0.5})
# Step 3: Also log apple (100g = 52 kcal)
e_apple = _log_entry(client, apple["id"], quantity=100.0, date=date)
# Step 4: Create outer meal from inner meal entry (0.5×) + apple entry
outer_resp = _from_log(client, "Outer", date, [inner_entry_id, e_apple["id"]])
assert outer_resp.status_code == 201, outer_resp.text
outer_meal_id = outer_resp.json()["meal"]["id"]
outer_entry_id = outer_resp.json()["entry"]["id"]
# Step 5: Update outer entry to 1.5×
client.put(f"/api/log/{outer_entry_id}", json={"quantity": 1.5})
# Summary before unpack:
# Outer at 1.5×: inner component (0.5 portion of inner meal):
# inner meal per 1.0 = 100g rice (130) + 1 egg (70) = 200 kcal
# inner at 0.5 portion = 100 kcal
# apple at 100g = 52 kcal
# Outer per 1.0 = 100 + 52 = 152 kcal
# Outer at 1.5× = 228 kcal
summary_before = _get_summary(client, date)
assert summary_before["totals"]["calories"] == 228.0
# Step 6: Unpack → should flatten to 4 leaf entries
unpack_resp = _unpack_meal(client, outer_meal_id, date)
assert unpack_resp.status_code == 200, unpack_resp.text
entries = unpack_resp.json()["entries"]
# Expected leaf foods:
# - rice: 100g * 0.5 (inner portion) * 1.5 (outer scaling) = 75g → 97.5 kcal
# - egg: 1.0 * 0.5 * 1.5 = 0.75 → 52.5 kcal
# - apple: 100g * 1.5 = 150g → 78 kcal
# Total = 97.5 + 52.5 + 78 = 228
assert len(entries) == 3 # rice, egg, apple (all leaf foods)
leaf_qtys = {}
for e in entries:
fid = e["food_id"]
leaf_qtys[fid] = leaf_qtys.get(fid, 0.0) + e["quantity"]
# rice: 100 * 0.5 * 1.5 = 75
assert leaf_qtys.get(rice["id"]) == 75.0
# egg: 1 * 0.5 * 1.5 = 0.75
assert leaf_qtys.get(egg["id"]) == 0.75
# apple: 100 * 1.5 = 150
assert leaf_qtys.get(apple["id"]) == 150.0
# Summary unchanged
summary_after = _get_summary(client, date)
assert summary_after["totals"]["calories"] == 228.0
# ── POST /api/meals/{meal_id}/unpack rollback on failure ────────────────────
def test_unpack_rollback_on_failure(client):
"""Force a failure mid-transaction by trying to unpack with a bad entry_id
→ nothing written, meal entry intact."""
date = "2025-08-02"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
resp = _from_log(client, "Lunch", date, [e1["id"]])
assert resp.status_code == 201
meal_id = resp.json()["meal"]["id"]
meal_entry_id = resp.json()["entry"]["id"]
# Try unpack with a non-existent entry_id
bad_resp = _unpack_meal(client, meal_id, date, entry_id=99999)
assert bad_resp.status_code == 404
# Meal entry still exists
log_entries = _get_log(client, date)
assert len(log_entries) == 1
assert log_entries[0]["id"] == meal_entry_id
def test_unpack_ambiguous_multiple_entries(client):
"""When multiple log entries reference the same meal on the same date,
unpack without entry_id returns 400."""
date = "2025-08-03"
f1 = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
e1 = _log_entry(client, f1["id"], quantity=200.0, date=date)
resp = _from_log(client, "Lunch", date, [e1["id"]])
assert resp.status_code == 201
meal_id = resp.json()["meal"]["id"]
# Log the meal again on the same date
_log_entry(client, meal_id, quantity=1.0, date=date)
# Ambiguous unpack
bad_resp = _unpack_meal(client, meal_id, date)
assert bad_resp.status_code == 400, bad_resp.text
assert "multiple" in bad_resp.json()["detail"].lower() or "ambiguous" in bad_resp.json()["detail"].lower()
# But unpack with explicit entry_id works
entries = _get_log(client, date)
for e in entries:
if e["food_id"] == meal_id:
ok_resp = _unpack_meal(client, meal_id, date, entry_id=e["id"])
assert ok_resp.status_code == 200
break
# ── PUT /api/meals/{meal_id}/components cycle rejection ─────────────────────
def test_components_cycle_rejection_transitive(client):
"""Build meal A, meal B; try to set B's components to include A, then
A's components to include B → 422 MealCycleError, graph unchanged."""
date = "2025-08-04"
# Create base foods + two meals
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
# Create meal A with rice
_log_entry(client, rice["id"], quantity=100.0, date=date)
resp_a = _from_log(client, "Meal A", date, _get_entry_ids(client, date))
meal_a_id = resp_a.json()["meal"]["id"]
# Create meal B with chicken
_log_entry(client, chicken["id"], quantity=200.0, date="2025-08-05")
resp_b = _from_log(client, "Meal B", "2025-08-05", _get_entry_ids(client, "2025-08-05"))
meal_b_id = resp_b.json()["meal"]["id"]
# Set meal A's components to include meal B
resp = _update_components(client, meal_a_id, [
{"food_id": meal_b_id, "quantity": 1.0},
{"food_id": rice["id"], "quantity": 100.0},
])
assert resp.status_code == 200, resp.text
# Now try to set meal B's components to include meal A → cycle!
resp = _update_components(client, meal_b_id, [
{"food_id": meal_a_id, "quantity": 1.0},
{"food_id": chicken["id"], "quantity": 200.0},
])
assert resp.status_code == 422, resp.text
# Meal B's components unchanged (still just chicken)
meal_b = client.get(f"/api/foods/{meal_b_id}").json()
b_food_ids = {c["food_id"] for c in meal_b["components"]}
assert b_food_ids == {chicken["id"]}
def test_components_direct_self_reference(client):
"""A meal trying to include itself → 422."""
date = "2025-08-06"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
_log_entry(client, rice["id"], quantity=100.0, date=date)
resp = _from_log(client, "Self Meal", date, _get_entry_ids(client, date))
meal_id = resp.json()["meal"]["id"]
# Try to make meal include itself
resp = _update_components(client, meal_id, [
{"food_id": rice["id"], "quantity": 100.0},
{"food_id": meal_id, "quantity": 1.0},
])
assert resp.status_code == 422, resp.text
def test_components_valid_non_cyclic_replacement(client):
"""Valid non-cyclic replacement → 200, components replaced."""
date = "2025-08-07"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
egg = _create_food(client, name="Egg", calories_per_unit=70.0, unit_type="count")
# Create meal with rice + chicken
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, chicken["id"], quantity=200.0, date=date)
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
meal_id = resp.json()["meal"]["id"]
# Verify initial components
meal = client.get(f"/api/foods/{meal_id}").json()
initial_food_ids = {c["food_id"] for c in meal["components"]}
assert initial_food_ids == {rice["id"], chicken["id"]}
# Replace components with chicken + egg
resp = _update_components(client, meal_id, [
{"food_id": chicken["id"], "quantity": 150.0},
{"food_id": egg["id"], "quantity": 2.0},
])
assert resp.status_code == 200, resp.text
updated_meal = resp.json()
new_food_ids = {c["food_id"] for c in updated_meal["components"]}
assert new_food_ids == {chicken["id"], egg["id"]}
assert updated_meal["is_meal"] is True
def test_components_meal_not_found(client):
"""PUT components on non-existent meal → 404."""
resp = _update_components(client, 99999, [
{"food_id": 1, "quantity": 1.0},
])
assert resp.status_code == 404
def test_components_food_not_found(client):
"""PUT components referencing non-existent food → 404."""
date = "2025-08-08"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
_log_entry(client, rice["id"], quantity=100.0, date=date)
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
meal_id = resp.json()["meal"]["id"]
resp = _update_components(client, meal_id, [
{"food_id": 99999, "quantity": 1.0},
])
assert resp.status_code == 404
# ── Summary: meals contribute real nutrition ────────────────────────────────
def test_summary_meal_contributes_real_nutrition(client):
"""Summary after from-log should show real derived nutrition (not 0)."""
date = "2025-08-09"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight",
protein_per_unit=2.7, carbs_per_unit=28.0, fat_per_unit=0.3)
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight",
protein_per_unit=31.0, carbs_per_unit=0.0, fat_per_unit=3.6)
_log_entry(client, rice["id"], quantity=200.0, date=date)
_log_entry(client, chicken["id"], quantity=150.0, date=date)
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
assert resp.status_code == 201
summary = _get_summary(client, date)
# rice: 130*200/100=260 kcal, 2.7*200/100=5.4g protein, 28*200/100=56g carbs, 0.3*200/100=0.6g fat
# chicken: 165*150/100=247.5 kcal, 31*150/100=46.5g protein, 0 carbs, 3.6*150/100=5.4g fat
# total: 507.5 kcal, 51.9g protein, 56g carbs, 6.0g fat
assert abs(summary["totals"]["calories"] - 507.5) < 0.01
assert abs(summary["totals"]["protein_g"] - 51.9) < 0.01
assert abs(summary["totals"]["carbs_g"] - 56.0) < 0.01
assert abs(summary["totals"]["fat_g"] - 6.0) < 0.01
# ── GET /api/foods/{id} for meals ───────────────────────────────────────────
def test_get_food_for_meal_returns_meal_read(client):
"""GET /api/foods/{id} for a meal returns components + computed nutrition."""
date = "2025-08-10"
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
chicken = _create_food(client, name="Chicken", calories_per_unit=165.0, unit_type="weight")
_log_entry(client, rice["id"], quantity=100.0, date=date)
_log_entry(client, chicken["id"], quantity=200.0, date=date)
resp = _from_log(client, "Lunch", date, _get_entry_ids(client, date))
meal_id = resp.json()["meal"]["id"]
meal = client.get(f"/api/foods/{meal_id}").json()
assert meal["is_meal"] is True
assert "components" in meal
assert len(meal["components"]) == 2
assert "computed_nutrition_per_meal" in meal
# rice 100g (130) + chicken 200g (330) = 460
assert abs(meal["computed_nutrition_per_meal"]["calories"] - 460.0) < 0.01
def test_get_food_for_non_meal_returns_food_read(client):
"""GET /api/foods/{id} for a non-meal returns plain FoodRead (no components)."""
rice = _create_food(client, name="Rice", calories_per_unit=130.0, unit_type="weight")
food = client.get(f"/api/foods/{rice['id']}").json()
assert food["is_meal"] is False
# Plain FoodRead shouldn't have components or computed_nutrition_per_meal
# (they might be present as empty/default — that's fine; key is it works)
assert food["name"] == "Rice"
# ── Helper: get all entry IDs for a date ─────────────────────────────────────
def _get_entry_ids(client, date: str) -> list[int]:
entries = client.get("/api/log", params={"date": date}).json()
return [e["id"] for e in entries]