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
+2 -1
View File
@@ -6,7 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from database import run_migrations from database import run_migrations
from routers import foods, log, off, targets from routers import foods, log, meals, off, targets
@asynccontextmanager @asynccontextmanager
@@ -30,6 +30,7 @@ app.include_router(foods.router)
app.include_router(log.router) app.include_router(log.router)
app.include_router(targets.router) app.include_router(targets.router)
app.include_router(off.router) app.include_router(off.router)
app.include_router(meals.router)
@app.get("/api/health") @app.get("/api/health")
+5 -3
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
from schemas import FoodCreate, FoodRead, FoodUpdate from schemas import FoodCreate, FoodRead, FoodUpdate, MealRead
from services.foods import ( from services.foods import (
BarcodeConflictError, BarcodeConflictError,
create_food, create_food,
@@ -43,9 +43,11 @@ def _list_foods(
include_deleted=include_deleted) include_deleted=include_deleted)
@router.get("/{food_id}", response_model=FoodRead) @router.get("/{food_id}")
def _get_food(food_id: int, db: Session = Depends(get_db)): def _get_food(food_id: int, db: Session = Depends(get_db)):
"""Get a single food, including soft-deleted ones (for historical logs).""" """Get a single food, including soft-deleted ones (for historical logs).
Returns MealRead (with components + computed nutrition) for meals,
plain FoodRead for non-meals."""
food = get_food(db, food_id) food = get_food(db, food_id)
if food is None: if food is None:
raise HTTPException(status_code=404, detail="Food not found") raise HTTPException(status_code=404, detail="Food not found")
+91
View File
@@ -0,0 +1,91 @@
"""Meals router (spec §3.2). Thin handlers — business logic in services/meals.py."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from database import get_db
from schemas import (
LogEntryRead,
MealComponentsUpdateRequest,
MealFromLogRequest,
MealFromLogResponse,
MealRead,
MealUnpackRequest,
MealUnpackResponse,
)
from services import meals as svc
router = APIRouter(prefix="/api/meals", tags=["meals"])
@router.post("/from-log", response_model=MealFromLogResponse, status_code=201)
def create_meal_from_log(data: MealFromLogRequest, db: Session = Depends(get_db)):
"""Create a meal food from selected daily log entries (§4.3).
Runs as one transaction: creates the meal + components, deletes source
entries, inserts one replacement entry. Rolls back entirely on failure.
"""
try:
meal, entry = svc.create_meal_from_log(
db, data.date, data.entry_ids, data.name,
)
except svc.EntryNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except svc.EntryDateMismatchError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return MealFromLogResponse(meal=meal, entry=entry)
@router.post("/{meal_id}/unpack", response_model=MealUnpackResponse)
def unpack_meal(
meal_id: int,
data: MealUnpackRequest,
db: Session = Depends(get_db),
):
"""Replace a logged meal entry with its leaf component entries (§4.4).
Components are resolved recursively — nested meals are flattened to
leaf foods with their scaling factors multiplied down.
Runs as one transaction. If multiple entries match the meal on this
date, specify ``entry_id`` to disambiguate.
"""
try:
entries = svc.unpack_meal(db, meal_id, data.date, data.entry_id)
except svc.EntryNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except svc.AmbiguousMealEntryError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return MealUnpackResponse(entries=entries)
@router.put("/{meal_id}/components", response_model=MealRead)
def update_meal_components(
meal_id: int,
data: MealComponentsUpdateRequest,
db: Session = Depends(get_db),
):
"""Replace a meal's component list (§3.2 PUT). Cycle-checked.
Components are validated for cycles (direct or transitive self-reference).
A 422 is returned when the update would create a cycle.
This is a full replacement — all existing components are removed and
replaced with the provided list.
"""
comps = [{"food_id": c.food_id, "quantity": c.quantity} for c in data.components]
try:
return svc.update_meal_components(db, meal_id, comps)
except svc.MealNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except svc.ComponentFoodNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except svc.MealCycleError as e:
raise HTTPException(status_code=422, detail=str(e))
+87 -18
View File
@@ -4,6 +4,8 @@ Defined separately from ORM models; services convert via from_attributes.
Validation happens here at the boundary (spec §8.1 rule 11). Validation happens here at the boundary (spec §8.1 rule 11).
""" """
from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from typing import Literal from typing import Literal
@@ -93,9 +95,55 @@ class FoodRead(BaseModel):
updated_at: datetime updated_at: datetime
# ── Meal components & MealRead (§3.2, TICKET-007) ────────────────────────────
class MealComponentRead(BaseModel):
"""A single component within a meal, with the component food embedded
so the frontend can render names/nutrition without N+1 lookups."""
model_config = ConfigDict(from_attributes=True)
food_id: int
quantity: float
food: "LogFoodRead"
class MealRead(FoodRead):
"""A meal food with its components and computed per-1.0-meal nutrition.
Returned by GET /api/foods/{id} when the food is a meal. For non-meal
foods the plain FoodRead is returned instead.
"""
components: list[MealComponentRead] = []
computed_nutrition_per_meal: dict[str, float] = Field(default_factory=dict)
# ── Log ────────────────────────────────────────────────────────────────────── # ── Log ──────────────────────────────────────────────────────────────────────
class LogFoodRead(BaseModel):
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
Includes name, brand, unit_type, calories_per_unit, and serving info so the
frontend can render log entries without N+1 lookups. Soft-deleted foods
render here (§2.1).
When is_meal is True, components is populated with the meal's components
(with their own food embedded) so the frontend can render collapsible rows.
"""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
brand: str | None
unit_type: UnitType
calories_per_unit: float | None
serving_size_g: float | None
serving_name: str | None
is_meal: bool
deleted_at: datetime | None
components: list[MealComponentRead] | None = None
class LogEntryCreate(BaseModel): class LogEntryCreate(BaseModel):
food_id: int food_id: int
quantity: float = Field(gt=0) quantity: float = Field(gt=0)
@@ -111,24 +159,6 @@ class LogEntryUpdate(BaseModel):
sort_order: int | None = None sort_order: int | None = None
class LogFoodRead(BaseModel):
"""Embedded food reference in log entry responses (spec §3.3, §8.3 rule 1).
Includes name, brand, unit_type, calories_per_unit, and serving info so the
frontend can render log entries without N+1 lookups. Soft-deleted foods
render here (§2.1)."""
model_config = ConfigDict(from_attributes=True)
id: int
name: str
brand: str | None
unit_type: UnitType
calories_per_unit: float | None
serving_size_g: float | None
serving_name: str | None
is_meal: bool
deleted_at: datetime | None
class LogEntryRead(BaseModel): class LogEntryRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -139,6 +169,45 @@ class LogEntryRead(BaseModel):
meal_slot: MealSlot | None meal_slot: MealSlot | None
sort_order: int sort_order: int
food: LogFoodRead food: LogFoodRead
computed_nutrition: dict[str, float] | None = None
# ── Meal endpoints (TICKET-007) ──────────────────────────────────────────────
class MealFromLogRequest(BaseModel):
"""Body for POST /api/meals/from-log."""
name: str
date: date
entry_ids: list[int] = Field(min_length=1)
class MealFromLogResponse(BaseModel):
"""Response for POST /api/meals/from-log."""
meal: MealRead
entry: LogEntryRead
class MealUnpackRequest(BaseModel):
"""Body for POST /api/meals/{meal_id}/unpack."""
date: date
entry_id: int | None = None
class MealUnpackResponse(BaseModel):
"""Response for POST /api/meals/{meal_id}/unpack."""
entries: list[LogEntryRead]
class MealComponentInput(BaseModel):
"""A single component in a PUT /api/meals/{meal_id}/components request."""
food_id: int
quantity: float = Field(gt=0)
class MealComponentsUpdateRequest(BaseModel):
"""Body for PUT /api/meals/{meal_id}/components — full replacement."""
components: list[MealComponentInput]
# ── Targets ────────────────────────────────────────────────────────────────── # ── Targets ──────────────────────────────────────────────────────────────────
+28 -5
View File
@@ -2,6 +2,9 @@
ORM objects never leave this module; functions return Pydantic schemas. ORM objects never leave this module; functions return Pydantic schemas.
Handlers stay thin (~15 lines) by calling into these functions. Handlers stay thin (~15 lines) by calling into these functions.
Meal foods are returned as MealRead (with components + computed nutrition)
via get_meal(). get_food() returns the appropriate type based on is_meal.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -10,7 +13,8 @@ from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models import DailyLogEntry, Food from models import DailyLogEntry, Food
from schemas import FoodCreate, FoodRead, FoodUpdate from schemas import FoodCreate, FoodRead, FoodUpdate, MealRead
from services.meals import _build_meal_read, load_meal_with_components
# ── Shared query helpers (§8.1 rule 7) ─────────────────────────────────────── # ── Shared query helpers (§8.1 rule 7) ───────────────────────────────────────
@@ -56,13 +60,32 @@ def list_foods(
return [FoodRead.model_validate(f) for f in foods] return [FoodRead.model_validate(f) for f in foods]
def get_food(db: Session, food_id: int) -> FoodRead | None: def get_food(db: Session, food_id: int) -> FoodRead | MealRead | None:
"""Get a single food by id. INCLUDES soft-deleted foods (historical log """Get a single food by id. INCLUDES soft-deleted foods (historical log
rendering depends on this — spec §2.1). Returns None for unknown id.""" rendering depends on this — spec §2.1).
food = db.get(Food, food_id)
Returns MealRead (with components + computed nutrition) for meal foods,
plain FoodRead for non-meals. Returns None for unknown id.
"""
food = load_meal_with_components(db, food_id)
if food is None: if food is None:
# Not a meal — try as a plain food
food = db.get(Food, food_id)
if food is None:
return None
return FoodRead.model_validate(food)
# Meal food: return MealRead with components
return _build_meal_read(food)
def get_meal(db: Session, meal_id: int) -> MealRead | None:
"""Get a meal food with components and computed nutrition.
Returns None if the food doesn't exist or is not a meal."""
meal = load_meal_with_components(db, meal_id)
if meal is None:
return None return None
return FoodRead.model_validate(food) return _build_meal_read(meal)
def create_food(db: Session, data: FoodCreate) -> FoodRead: def create_food(db: Session, data: FoodCreate) -> FoodRead:
+119 -23
View File
@@ -12,15 +12,16 @@ ORM objects never leave this module; functions return Pydantic schemas.
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload, selectinload
from models import DailyLogEntry, Food from models import DailyLogEntry, Food, MealComponent
from schemas import ( from schemas import (
DaySummaryNutrition, DaySummaryNutrition,
DaySummaryResponse, DaySummaryResponse,
LogEntryCreate, LogEntryCreate,
LogEntryRead, LogEntryRead,
LogEntryUpdate, LogEntryUpdate,
MealComponentRead,
) )
from services import nutrition from services import nutrition
from services import targets as targets_svc from services import targets as targets_svc
@@ -39,20 +40,82 @@ def _load_entry_with_food(db: Session, entry_id: int) -> DailyLogEntry | None:
) )
def _build_log_food_dict(food: Food) -> dict:
"""Convert a Food ORM to dict for LogFoodRead, including nested components
when the food is a meal."""
data = {
"id": food.id,
"name": food.name,
"brand": food.brand,
"unit_type": food.unit_type,
"calories_per_unit": food.calories_per_unit,
"serving_size_g": food.serving_size_g,
"serving_name": food.serving_name,
"is_meal": food.is_meal,
"deleted_at": food.deleted_at,
}
if food.is_meal and hasattr(food, "components"):
comps = []
for mc in food.components:
cf = mc.food
comps.append(MealComponentRead(
food_id=mc.food_id,
quantity=mc.quantity,
food={
"id": cf.id,
"name": cf.name,
"brand": cf.brand,
"unit_type": cf.unit_type,
"calories_per_unit": cf.calories_per_unit,
"serving_size_g": cf.serving_size_g,
"serving_name": cf.serving_name,
"is_meal": cf.is_meal,
"deleted_at": cf.deleted_at,
},
))
data["components"] = comps
else:
data["components"] = None
return data
# ── CRUD ───────────────────────────────────────────────────────────────────── # ── CRUD ─────────────────────────────────────────────────────────────────────
def get_log_entries(db: Session, lookup_date: date) -> list[LogEntryRead]: def get_log_entries(db: Session, lookup_date: date) -> list[LogEntryRead]:
"""All entries for a date, ordered by sort_order then id, with embedded """All entries for a date, ordered by sort_order then id, with embedded
food data eagerly loaded. Soft-deleted foods still render (§2.1).""" food data eagerly loaded. Meal foods include their nested components
so the frontend can render collapsible rows (§3.3).
Soft-deleted foods still render (§2.1).
"""
stmt = ( stmt = (
select(DailyLogEntry) select(DailyLogEntry)
.where(DailyLogEntry.date == lookup_date) .where(DailyLogEntry.date == lookup_date)
.options(joinedload(DailyLogEntry.food)) .options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
.order_by(DailyLogEntry.sort_order, DailyLogEntry.id) .order_by(DailyLogEntry.sort_order, DailyLogEntry.id)
) )
entries = db.scalars(stmt).all() entries = db.scalars(stmt).unique().all()
return [LogEntryRead.model_validate(e) for e in entries] result: list[LogEntryRead] = []
for entry in entries:
food_data = _build_log_food_dict(entry.food)
# Compute nutrition for this entry (handles meals recursively)
entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity)
result.append(LogEntryRead.model_validate({
"id": entry.id,
"date": entry.date,
"food_id": entry.food_id,
"quantity": entry.quantity,
"meal_slot": entry.meal_slot,
"sort_order": entry.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
}))
return result
def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead: def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead:
@@ -87,7 +150,19 @@ def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead:
db.commit() db.commit()
# Re-query with eager-loaded food for the response # Re-query with eager-loaded food for the response
return LogEntryRead.model_validate(_load_entry_with_food(db, entry.id)) loaded = _load_entry_with_food(db, entry.id)
food_data = _build_log_food_dict(loaded.food)
entry_nut = nutrition.entry_nutrition(loaded.food, loaded.quantity)
return LogEntryRead.model_validate({
"id": loaded.id,
"date": loaded.date,
"food_id": loaded.food_id,
"quantity": loaded.quantity,
"meal_slot": loaded.meal_slot,
"sort_order": loaded.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
})
def update_log_entry( def update_log_entry(
@@ -109,7 +184,29 @@ def update_log_entry(
setattr(entry, field, value) setattr(entry, field, value)
db.commit() db.commit()
return LogEntryRead.model_validate(_load_entry_with_food(db, entry_id))
# Re-query with eager-loaded food + components for the response
loaded = db.scalar(
select(DailyLogEntry)
.where(DailyLogEntry.id == entry_id)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
)
food_data = _build_log_food_dict(loaded.food)
entry_nut = nutrition.entry_nutrition(loaded.food, loaded.quantity)
return LogEntryRead.model_validate({
"id": loaded.id,
"date": loaded.date,
"food_id": loaded.food_id,
"quantity": loaded.quantity,
"meal_slot": loaded.meal_slot,
"sort_order": loaded.sort_order,
"food": food_data,
"computed_nutrition": entry_nut,
})
def delete_log_entry(db: Session, entry_id: int) -> bool: def delete_log_entry(db: Session, entry_id: int) -> bool:
@@ -141,14 +238,12 @@ class FoodNotAvailableError(Exception):
def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse: def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
"""Compute nutrition totals for a date vs. the applicable target. """Compute nutrition totals for a date vs. the applicable target.
1. Loads all log entries for the date with food data eagerly joined. 1. Loads all log entries for the date with food data and meal components
2. For each entry, scales the food's per-unit nutrition to the logged eagerly joined.
quantity via nutrition.entry_nutrition() — weight-type foods get 2. For each entry, uses nutrition.entry_nutrition() which now handles
(qty/100)× scaling, count-type foods get qty× scaling (§2.1). meal foods recursively by summing their component nutrition (§2.2).
3. Meal entries (is_meal=True) currently contribute 0 because their Cycle detection prevents infinite loops on inconsistent data.
per_unit fields are null per the CHECK constraint. 3. Looks up the target covering the date via the half-open interval
TODO: TICKET-007 — replace with recursive component summation.
4. Looks up the target covering the date via the half-open interval
lookup from targets.get_target_for_date(). Returns null if none. lookup from targets.get_target_for_date(). Returns null if none.
Returns a DaySummaryResponse with summed totals and the applicable Returns a DaySummaryResponse with summed totals and the applicable
@@ -157,15 +252,16 @@ def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse:
entries = db.scalars( entries = db.scalars(
select(DailyLogEntry) select(DailyLogEntry)
.where(DailyLogEntry.date == lookup_date) .where(DailyLogEntry.date == lookup_date)
.options(joinedload(DailyLogEntry.food)) .options(
).all() joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
).unique().all()
# Sum nutrition across all entries for the date. # Sum nutrition across all entries for the date.
# TODO: TICKET-007 — meal entries currently contribute 0 because their # entry_nutrition() now handles meal foods by recursive component
# per_unit fields are null per the CHECK constraint. Real meal nutrition # summation (TICKET-007).
# will be derived by recursively summing component foods' nutrition.
# When that lands, replace the flat entry_nutrition() call with a
# meal-aware sum function from services/nutrition.py.
totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS} totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS}
for entry in entries: for entry in entries:
entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity) entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity)
+546 -2
View File
@@ -1,7 +1,551 @@
"""Meal business logic: composition, recursion, cycle detection (spec §2.2). """Meal business logic: composition, recursion, cycle detection (spec §2.2).
Multi-write operations here own their transactions (spec §8.1 rule 6): Multi-write operations here own their transactions (spec §8.1 rule 6):
commit once at the end or roll back entirely. create_meal_from_log, unpack_meal, and update_meal_components each commit once
at the end or roll back entirely — never partial writes.
TODO: from-log, unpack, component replacement with cycle checks. ORM objects never leave this module; functions return Pydantic schemas
(spec §8.1 rule 3).
""" """
from __future__ import annotations
from datetime import date, datetime, timezone
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload, selectinload
from models import DailyLogEntry, Food, MealComponent
from schemas import (
FoodRead,
LogEntryRead,
MealComponentRead,
MealRead,
)
from services import nutrition
# ── Helpers ──────────────────────────────────────────────────────────────────
def _now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def _load_entry_with_food(db: Session, entry_id: int) -> DailyLogEntry | None:
"""Eager-load a single entry with its food relationship."""
return db.scalar(
select(DailyLogEntry)
.where(DailyLogEntry.id == entry_id)
.options(joinedload(DailyLogEntry.food))
)
def _load_entry_with_food_components(db: Session, entry_id: int) -> DailyLogEntry | None:
"""Eager-load a single entry with food and nested meal components."""
return db.scalar(
select(DailyLogEntry)
.where(DailyLogEntry.id == entry_id)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
)
def _build_log_entry_read(entry: DailyLogEntry) -> LogEntryRead:
"""Convert a DailyLogEntry ORM object to LogEntryRead, populating
nested meal components on the embedded food when is_meal is True."""
data = {
"id": entry.id,
"date": entry.date,
"food_id": entry.food_id,
"quantity": entry.quantity,
"meal_slot": entry.meal_slot,
"sort_order": entry.sort_order,
}
# Build the embedded food
food = entry.food
food_data = {
"id": food.id,
"name": food.name,
"brand": food.brand,
"unit_type": food.unit_type,
"calories_per_unit": food.calories_per_unit,
"serving_size_g": food.serving_size_g,
"serving_name": food.serving_name,
"is_meal": food.is_meal,
"deleted_at": food.deleted_at,
}
# Populate nested components for meal foods
if food.is_meal and hasattr(food, "components"):
comps = []
for mc in food.components:
comp_food = mc.food
comps.append(MealComponentRead(
food_id=mc.food_id,
quantity=mc.quantity,
food={
"id": comp_food.id,
"name": comp_food.name,
"brand": comp_food.brand,
"unit_type": comp_food.unit_type,
"calories_per_unit": comp_food.calories_per_unit,
"serving_size_g": comp_food.serving_size_g,
"serving_name": comp_food.serving_name,
"is_meal": comp_food.is_meal,
"deleted_at": comp_food.deleted_at,
},
))
food_data["components"] = comps
else:
food_data["components"] = None
data["food"] = food_data
# Compute nutrition for this entry (handles meals recursively)
data["computed_nutrition"] = nutrition.entry_nutrition(entry.food, entry.quantity)
return LogEntryRead.model_validate(data)
def _build_meal_read(meal: Food) -> MealRead:
"""Convert a meal Food ORM to MealRead with components & computed nutrition."""
# Base food fields
meal_data = {
"id": meal.id,
"name": meal.name,
"brand": meal.brand,
"barcode": meal.barcode,
"source": meal.source,
"is_meal": meal.is_meal,
"unit_type": meal.unit_type,
"calories_per_unit": meal.calories_per_unit,
"protein_per_unit": meal.protein_per_unit,
"carbs_per_unit": meal.carbs_per_unit,
"fat_per_unit": meal.fat_per_unit,
"fiber_per_unit": meal.fiber_per_unit,
"saturated_fat_per_unit": meal.saturated_fat_per_unit,
"sugars_per_unit": meal.sugars_per_unit,
"sodium_per_unit": meal.sodium_per_unit,
"serving_size_g": meal.serving_size_g,
"serving_name": meal.serving_name,
"deleted_at": meal.deleted_at,
"created_at": meal.created_at,
"updated_at": meal.updated_at,
}
# Build component list
comps = []
if hasattr(meal, "components"):
for mc in meal.components:
comp_food = mc.food
comps.append(MealComponentRead(
food_id=mc.food_id,
quantity=mc.quantity,
food={
"id": comp_food.id,
"name": comp_food.name,
"brand": comp_food.brand,
"unit_type": comp_food.unit_type,
"calories_per_unit": comp_food.calories_per_unit,
"serving_size_g": comp_food.serving_size_g,
"serving_name": comp_food.serving_name,
"is_meal": comp_food.is_meal,
"deleted_at": comp_food.deleted_at,
},
))
meal_data["components"] = comps
meal_data["computed_nutrition_per_meal"] = nutrition.entry_nutrition(meal, 1.0)
return MealRead.model_validate(meal_data)
# ── Meal resolution (for foods.py get_food) ──────────────────────────────────
def load_meal_with_components(db: Session, meal_id: int) -> Food | None:
"""Eager-load a meal food with its components and their foods.
Returns None if the food doesn't exist or is not a meal.
"""
result = db.scalar(
select(Food)
.where(Food.id == meal_id, Food.is_meal.is_(True))
.options(
selectinload(Food.components).joinedload(MealComponent.food)
)
)
return result
# ── Cycle detection ──────────────────────────────────────────────────────────
class MealCycleError(Exception):
"""Raised when a component update would create a cycle in the meal graph."""
def __init__(self, meal_id: int, food_id: int):
super().__init__(
f"Adding food {food_id} as a component of meal {meal_id} "
f"would create a cycle"
)
self.meal_id = meal_id
self.food_id = food_id
def _check_cycle(db: Session, meal_id: int, proposed_food_ids: list[int]) -> None:
"""Raise MealCycleError if adding proposed_food_ids as components of
meal_id would create a cycle (directly or transitively).
Direct self-reference (food_id == meal_id) is also considered a cycle.
"""
# Build adjacency from all existing meal_components
stmt = select(MealComponent.meal_id, MealComponent.food_id)
rows = db.execute(stmt).all()
adj: dict[int, set[int]] = {}
for row in rows:
adj.setdefault(row.meal_id, set()).add(row.food_id)
# Add proposed edges
for fid in proposed_food_ids:
adj.setdefault(meal_id, set()).add(fid)
# DFS from each proposed food_id; if meal_id is reachable, it's a cycle
for fid in proposed_food_ids:
if _dfs_reachable(adj, fid, meal_id, set()):
raise MealCycleError(meal_id, fid)
def _dfs_reachable(
adj: dict[int, set[int]], current: int, target: int, visited: set[int],
) -> bool:
"""Return True if target is reachable from current in the adjacency graph."""
if current == target:
return True
if current in visited:
return False
visited.add(current)
for neighbor in adj.get(current, set()):
if _dfs_reachable(adj, neighbor, target, visited):
return True
return False
# ── create_meal_from_log (§4.3) ──────────────────────────────────────────────
def create_meal_from_log(
db: Session, lookup_date: date, entry_ids: list[int], name: str,
) -> tuple[FoodRead, LogEntryRead]:
"""Create a meal from selected daily_log entries. Runs as one transaction.
Steps:
1. Load the source entries; validate they all exist and belong to ``date``.
2. Create a new Food (is_meal=True, source="meal").
3. Create MealComponent rows (quantity = original entry.quantity).
4. Delete the source entries.
5. Insert ONE replacement entry with quantity=1.0.
6. Commit once; roll back entirely on any failure.
Returns (FoodRead of the new meal, LogEntryRead of the replacement entry).
"""
# 1. Load & validate source entries
source_entries: list[DailyLogEntry] = []
for eid in entry_ids:
entry = db.get(DailyLogEntry, eid)
if entry is None:
raise EntryNotFoundError(eid)
if entry.date != lookup_date:
raise EntryDateMismatchError(eid, entry.date, lookup_date)
source_entries.append(entry)
if not source_entries:
raise ValueError("No valid source entries provided")
# 2. Create the meal food
now = _now()
meal = Food(
name=name,
brand=None,
barcode=None,
source="meal",
is_meal=True,
unit_type="weight", # placeholder — meals use quantity as scaling factor
calories_per_unit=None,
protein_per_unit=None,
carbs_per_unit=None,
fat_per_unit=None,
fiber_per_unit=None,
saturated_fat_per_unit=None,
sugars_per_unit=None,
sodium_per_unit=None,
serving_size_g=None,
serving_name=None,
off_data=None,
deleted_at=None,
created_at=now,
updated_at=now,
)
db.add(meal)
db.flush() # get meal.id
# 3. Create MealComponent rows
for entry in source_entries:
mc = MealComponent(
meal_id=meal.id,
food_id=entry.food_id,
quantity=entry.quantity,
)
db.add(mc)
# 4. Delete source entries
max_sort = 0
meal_slots: set[str | None] = set()
for entry in source_entries:
if entry.sort_order > max_sort:
max_sort = entry.sort_order
meal_slots.add(entry.meal_slot)
db.delete(entry)
# 5. Insert replacement entry
# meal_slot: use the first source entry's slot if all agree, else None
replacement_slot = source_entries[0].meal_slot if len(meal_slots) == 1 else None
replacement = DailyLogEntry(
date=lookup_date,
food_id=meal.id,
quantity=1.0,
meal_slot=replacement_slot,
sort_order=max_sort,
created_at=now,
)
db.add(replacement)
db.flush() # get replacement.id
# Commit
db.commit()
# Re-query for response with eager-loaded relationships
meal_loaded = load_meal_with_components(db, meal.id)
entry_loaded = _load_entry_with_food_components(db, replacement.id)
meal_read = _build_meal_read(meal_loaded)
entry_read = _build_log_entry_read(entry_loaded)
return meal_read, entry_read
# ── unpack_meal (§4.4) ──────────────────────────────────────────────────────
def unpack_meal(
db: Session, meal_id: int, lookup_date: date, entry_id: int | None = None,
) -> list[LogEntryRead]:
"""Replace a logged meal entry with its component entries.
Runs as one transaction:
1. Find the target log entry (by entry_id or auto-detect).
2. Flatten the meal recursively to LEAF foods.
3. Insert new entries for each leaf.
4. Delete the original meal entry.
5. Commit once; roll back entirely on any failure.
If ``entry_id`` is given, it must point at a log entry for ``meal_id``
on ``lookup_date``. If ``entry_id`` is None, the entry is auto-detected:
exactly one matching entry must exist, or an error is raised.
"""
# 1. Find the target log entry
if entry_id is not None:
target = db.scalar(
select(DailyLogEntry)
.where(
DailyLogEntry.id == entry_id,
DailyLogEntry.food_id == meal_id,
DailyLogEntry.date == lookup_date,
)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
)
if target is None:
raise EntryNotFoundError(entry_id)
else:
candidates = db.scalars(
select(DailyLogEntry)
.where(
DailyLogEntry.food_id == meal_id,
DailyLogEntry.date == lookup_date,
)
.options(
joinedload(DailyLogEntry.food)
.selectinload(Food.components)
.joinedload(MealComponent.food)
)
).unique().all()
if len(candidates) == 0:
raise ValueError(
f"No log entry found for meal {meal_id} on {lookup_date}"
)
if len(candidates) > 1:
raise AmbiguousMealEntryError(meal_id, lookup_date, len(candidates))
target = candidates[0]
meal_food = target.food
if not meal_food.is_meal:
raise ValueError(f"Food {meal_id} is not a meal")
# 2. Flatten recursively to leaf foods
leaf_entries = _flatten_meal(meal_food, target.quantity)
# 3. Insert new entries for each leaf
sort_base = target.sort_order
new_entries: list[DailyLogEntry] = []
for i, (leaf_food_id, eff_qty) in enumerate(leaf_entries):
entry = DailyLogEntry(
date=lookup_date,
food_id=leaf_food_id,
quantity=eff_qty,
meal_slot=target.meal_slot,
sort_order=sort_base + i,
created_at=_now(),
)
db.add(entry)
new_entries.append(entry)
# 4. Delete the original meal entry
db.delete(target)
# Commit
db.commit()
# Re-query for responses with eager-loaded food
result: list[LogEntryRead] = []
for entry in new_entries:
loaded = _load_entry_with_food(db, entry.id)
result.append(_build_log_entry_read(loaded))
return result
def _flatten_meal(
food: Food, scaling: float, visited: set[int] | None = None,
) -> list[tuple[int, float]]:
"""Recursively flatten a meal to its leaf foods.
Returns a list of (food_id, effective_quantity) for each leaf food.
Nested meals are expanded; their scaling factors are multiplied down.
``visited`` guards against cycles (safety net; cycles should be prevented
by the write path). A cycled branch returns an empty list.
"""
if visited is None:
visited = set()
if food.id in visited:
return []
visited.add(food.id)
if not food.is_meal:
return [(food.id, scaling)]
leaves: list[tuple[int, float]] = []
for component in food.components:
# component.quantity is the amount in ONE full meal; multiply by
# the scaling factor for this log entry.
leaves.extend(
_flatten_meal(component.food, component.quantity * scaling, visited)
)
return leaves
# ── update_meal_components (§3.2 PUT, cycle-checked) ─────────────────────────
def update_meal_components(
db: Session, meal_id: int, components: list[dict],
) -> MealRead:
"""Replace the component list for a meal food.
``components`` is a list of dicts with ``food_id`` and ``quantity`` keys.
Validates that each food_id exists, quantity > 0, and the new component
list doesn't create a cycle (direct or transitive self-reference).
Runs in one transaction: deletes existing components, inserts new ones,
commits. Returns the updated MealRead.
"""
# Validate meal exists and is a meal
meal = db.get(Food, meal_id)
if meal is None or not meal.is_meal:
raise MealNotFoundError(meal_id)
# Collect proposed food_ids
proposed_ids = [c["food_id"] for c in components]
# Validate each food_id exists
for fid in proposed_ids:
if db.get(Food, fid) is None:
raise ComponentFoodNotFoundError(fid)
# Cycle check: would adding these components create a cycle?
_check_cycle(db, meal_id, proposed_ids)
# Replace: delete existing, insert new
db.execute(
MealComponent.__table__.delete().where(MealComponent.meal_id == meal_id)
)
for c in components:
mc = MealComponent(
meal_id=meal_id,
food_id=c["food_id"],
quantity=c["quantity"],
)
db.add(mc)
db.commit()
# Re-query with components loaded
meal = load_meal_with_components(db, meal_id)
return _build_meal_read(meal)
# ── Errors ───────────────────────────────────────────────────────────────────
class EntryNotFoundError(Exception):
def __init__(self, entry_id: int):
super().__init__(f"Log entry {entry_id} not found")
self.entry_id = entry_id
class EntryDateMismatchError(Exception):
def __init__(self, entry_id: int, entry_date: date, expected_date: date):
super().__init__(
f"Log entry {entry_id} has date {entry_date}, "
f"not the expected {expected_date}"
)
self.entry_id = entry_id
self.entry_date = entry_date
self.expected_date = expected_date
class AmbiguousMealEntryError(Exception):
def __init__(self, meal_id: int, lookup_date: date, count: int):
super().__init__(
f"Multiple ({count}) log entries for meal {meal_id} on {lookup_date}. "
f"Specify entry_id to disambiguate."
)
self.meal_id = meal_id
self.lookup_date = lookup_date
self.count = count
class MealNotFoundError(Exception):
def __init__(self, meal_id: int):
super().__init__(f"Meal {meal_id} not found or is not a meal")
self.meal_id = meal_id
class ComponentFoodNotFoundError(Exception):
def __init__(self, food_id: int):
super().__init__(f"Component food {food_id} not found")
self.food_id = food_id
+67 -19
View File
@@ -5,6 +5,10 @@ Routers never compute nutrition; the frontend never re-derives it.
- unit_type "weight": quantity is grams; nutrition = (quantity / 100) × per_unit - unit_type "weight": quantity is grams; nutrition = (quantity / 100) × per_unit
- unit_type "count": quantity is item count; nutrition = quantity × per_unit - unit_type "count": quantity is item count; nutrition = quantity × per_unit
- meal: quantity is a scaling factor (1.0 = one full meal) - meal: quantity is a scaling factor (1.0 = one full meal)
Meal nutrition is derived by recursive component summation (§2.2), with cycle
detection. A cycle returns zeros for that branch (safety net — writes prevent
cycles, but reads must never infinite-loop).
""" """
from models import Food from models import Food
@@ -42,31 +46,75 @@ def scale_to_quantity(per_unit: float | None, quantity: float, unit_type: str) -
raise ValueError(f"unknown unit_type: {unit_type!r}") raise ValueError(f"unknown unit_type: {unit_type!r}")
def entry_calories(food: Food, quantity: float) -> float: def entry_calories(food: Food, quantity: float, visited: set[int] | None = None) -> float:
"""Calories for a single (non-meal) food at a logged quantity. """Calories for a food at a logged quantity.
TODO: handle is_meal foods by summing scaled component nutrition For regular foods: scales per_unit by quantity using unit_type.
(recursively, with cycle detection — spec §2.2). For meals: recursively sums scaled component nutrition. Cycle detection
prevents infinite loops — a cycled branch returns 0 (safety net; writes
should prevent cycles from being created).
""" """
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type) if visited is None:
visited = set()
if food.id in visited:
return 0.0
if not food.is_meal:
return scale_to_quantity(food.calories_per_unit, quantity, food.unit_type)
# Meal: recurse into components
visited.add(food.id)
total = 0.0
for component in food.components:
# component.quantity is the amount in ONE full meal; multiply by the
# entry's scaling factor to get the effective quantity for this log entry.
total += entry_calories(component.food, component.quantity * quantity, visited)
return total
def entry_nutrition(food: Food, quantity: float) -> dict[str, float]: def entry_nutrition(
food: Food, quantity: float, visited: set[int] | None = None,
) -> dict[str, float]:
"""Return all nutrition fields scaled to a logged quantity. """Return all nutrition fields scaled to a logged quantity.
Each field is resolved via scale_to_quantity using the food's unit_type. For regular foods: each field is resolved via scale_to_quantity using
NULL per-unit values contribute 0.0, not an error. the food's unit_type. NULL per-unit values contribute 0.0.
Meal foods (is_meal=True) have null per_unit fields per the CHECK For meals: recursively sums scaled component nutrition (§2.2). Cycle
constraint, so they naturally contribute 0 for all fields. detection prevents infinite loops — a cycled branch returns zeros for
TODO: TICKET-007 — real meal nutrition will sum scaled component all fields (safety net; writes should prevent cycles from being created).
foods recursively. Until then, meal entries contribute 0.
The ``visited`` set tracks food IDs on the current recursion path.
Callers should NOT pre-populate it — it defaults to an empty set and
is only used internally for recursion.
""" """
return { if visited is None:
field: scale_to_quantity( visited = set()
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
quantity, if food.id in visited:
food.unit_type, # Cycle detected — safety net; return zeros for this branch.
return {field: 0.0 for field in NUTRITION_FIELDS}
if not food.is_meal:
return {
field: scale_to_quantity(
getattr(food, _FIELD_TO_PER_UNIT_COL[field], None),
quantity,
food.unit_type,
)
for field in NUTRITION_FIELDS
}
# Meal: recurse into components, multiplying the scaling factor down.
visited.add(food.id)
totals = {field: 0.0 for field in NUTRITION_FIELDS}
for component in food.components:
# component.quantity is the amount in ONE full meal; multiply by the
# entry's scaling factor to get the effective quantity for this log entry.
component_nut = entry_nutrition(
component.food, component.quantity * quantity, visited,
) )
for field in NUTRITION_FIELDS for field in NUTRITION_FIELDS:
} totals[field] += component_nut[field]
return totals
+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]