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