Files
calcount/backend/tests/test_meals.py
T

774 lines
30 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.
"""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]