"""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]