diff --git a/backend/main.py b/backend/main.py index e5d8fb7..5e2eb0d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from database import run_migrations -from routers import foods, log, off, targets +from routers import foods, log, meals, off, targets @asynccontextmanager @@ -30,6 +30,7 @@ app.include_router(foods.router) app.include_router(log.router) app.include_router(targets.router) app.include_router(off.router) +app.include_router(meals.router) @app.get("/api/health") diff --git a/backend/routers/foods.py b/backend/routers/foods.py index b6d8445..a8138ec 100644 --- a/backend/routers/foods.py +++ b/backend/routers/foods.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from database import get_db -from schemas import FoodCreate, FoodRead, FoodUpdate +from schemas import FoodCreate, FoodRead, FoodUpdate, MealRead from services.foods import ( BarcodeConflictError, create_food, @@ -43,9 +43,11 @@ def _list_foods( 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)): - """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) if food is None: raise HTTPException(status_code=404, detail="Food not found") diff --git a/backend/routers/meals.py b/backend/routers/meals.py new file mode 100644 index 0000000..afc2a37 --- /dev/null +++ b/backend/routers/meals.py @@ -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)) diff --git a/backend/schemas.py b/backend/schemas.py index ff0da0a..e0a0209 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -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). """ +from __future__ import annotations + from datetime import date, datetime from typing import Literal @@ -93,9 +95,55 @@ class FoodRead(BaseModel): 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 ────────────────────────────────────────────────────────────────────── +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): food_id: int quantity: float = Field(gt=0) @@ -111,24 +159,6 @@ class LogEntryUpdate(BaseModel): 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): model_config = ConfigDict(from_attributes=True) @@ -139,6 +169,45 @@ class LogEntryRead(BaseModel): meal_slot: MealSlot | None sort_order: int 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 ────────────────────────────────────────────────────────────────── @@ -218,4 +287,4 @@ class DaySummaryResponse(BaseModel): date: date totals: DaySummaryNutrition - target: TargetRead | None + target: TargetRead | None \ No newline at end of file diff --git a/backend/services/foods.py b/backend/services/foods.py index b750b6e..057705c 100644 --- a/backend/services/foods.py +++ b/backend/services/foods.py @@ -2,6 +2,9 @@ ORM objects never leave this module; functions return Pydantic schemas. 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 @@ -10,7 +13,8 @@ from sqlalchemy import desc, func, select from sqlalchemy.orm import Session 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) ─────────────────────────────────────── @@ -56,13 +60,32 @@ def list_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 - rendering depends on this — spec §2.1). Returns None for unknown id.""" - food = db.get(Food, food_id) + rendering depends on this — spec §2.1). + + 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: + # 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 FoodRead.model_validate(food) + return _build_meal_read(meal) def create_food(db: Session, data: FoodCreate) -> FoodRead: diff --git a/backend/services/log.py b/backend/services/log.py index 7991334..95e82ed 100644 --- a/backend/services/log.py +++ b/backend/services/log.py @@ -12,15 +12,16 @@ ORM objects never leave this module; functions return Pydantic schemas. from datetime import date, datetime, timezone 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 ( DaySummaryNutrition, DaySummaryResponse, LogEntryCreate, LogEntryRead, LogEntryUpdate, + MealComponentRead, ) from services import nutrition 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 ───────────────────────────────────────────────────────────────────── def get_log_entries(db: Session, lookup_date: date) -> list[LogEntryRead]: """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 = ( select(DailyLogEntry) .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) ) - entries = db.scalars(stmt).all() - return [LogEntryRead.model_validate(e) for e in entries] + entries = db.scalars(stmt).unique().all() + 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: @@ -87,7 +150,19 @@ def create_log_entry(db: Session, data: LogEntryCreate) -> LogEntryRead: db.commit() # 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( @@ -109,7 +184,29 @@ def update_log_entry( setattr(entry, field, value) 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: @@ -141,14 +238,12 @@ class FoodNotAvailableError(Exception): def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse: """Compute nutrition totals for a date vs. the applicable target. - 1. Loads all log entries for the date with food data eagerly joined. - 2. For each entry, scales the food's per-unit nutrition to the logged - quantity via nutrition.entry_nutrition() — weight-type foods get - (qty/100)× scaling, count-type foods get qty× scaling (§2.1). - 3. Meal entries (is_meal=True) currently contribute 0 because their - per_unit fields are null per the CHECK constraint. - TODO: TICKET-007 — replace with recursive component summation. - 4. Looks up the target covering the date via the half-open interval + 1. Loads all log entries for the date with food data and meal components + eagerly joined. + 2. For each entry, uses nutrition.entry_nutrition() which now handles + meal foods recursively by summing their component nutrition (§2.2). + Cycle detection prevents infinite loops on inconsistent data. + 3. Looks up the target covering the date via the half-open interval lookup from targets.get_target_for_date(). Returns null if none. 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( select(DailyLogEntry) .where(DailyLogEntry.date == lookup_date) - .options(joinedload(DailyLogEntry.food)) - ).all() + .options( + joinedload(DailyLogEntry.food) + .selectinload(Food.components) + .joinedload(MealComponent.food) + ) + ).unique().all() # Sum nutrition across all entries for the date. - # TODO: TICKET-007 — meal entries currently contribute 0 because their - # per_unit fields are null per the CHECK constraint. Real meal nutrition - # 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. + # entry_nutrition() now handles meal foods by recursive component + # summation (TICKET-007). totals = {field: 0.0 for field in nutrition.NUTRITION_FIELDS} for entry in entries: entry_nut = nutrition.entry_nutrition(entry.food, entry.quantity) @@ -178,4 +274,4 @@ def get_day_summary(db: Session, lookup_date: date) -> DaySummaryResponse: date=lookup_date, totals=DaySummaryNutrition(**totals), target=target, - ) + ) \ No newline at end of file diff --git a/backend/services/meals.py b/backend/services/meals.py index 2d2fb01..0e752c7 100644 --- a/backend/services/meals.py +++ b/backend/services/meals.py @@ -1,7 +1,551 @@ """Meal business logic: composition, recursion, cycle detection (spec §2.2). 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 diff --git a/backend/services/nutrition.py b/backend/services/nutrition.py index 92902c8..8a782e7 100644 --- a/backend/services/nutrition.py +++ b/backend/services/nutrition.py @@ -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 "count": quantity is item count; nutrition = quantity × per_unit - 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 @@ -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}") -def entry_calories(food: Food, quantity: float) -> float: - """Calories for a single (non-meal) food at a logged quantity. +def entry_calories(food: Food, quantity: float, visited: set[int] | None = None) -> float: + """Calories for a food at a logged quantity. - TODO: handle is_meal foods by summing scaled component nutrition - (recursively, with cycle detection — spec §2.2). + For regular foods: scales per_unit by quantity using unit_type. + 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. - Each field is resolved via scale_to_quantity using the food's unit_type. - NULL per-unit values contribute 0.0, not an error. + For regular foods: each field is resolved via scale_to_quantity using + 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 - constraint, so they naturally contribute 0 for all fields. - TODO: TICKET-007 — real meal nutrition will sum scaled component - foods recursively. Until then, meal entries contribute 0. + For meals: recursively sums scaled component nutrition (§2.2). Cycle + detection prevents infinite loops — a cycled branch returns zeros for + all fields (safety net; writes should prevent cycles from being created). + + 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 { - field: scale_to_quantity( - getattr(food, _FIELD_TO_PER_UNIT_COL[field], None), - quantity, - food.unit_type, + if visited is None: + visited = set() + + if food.id in visited: + # 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 \ No newline at end of file diff --git a/backend/tests/test_meals.py b/backend/tests/test_meals.py new file mode 100644 index 0000000..06edd27 --- /dev/null +++ b/backend/tests/test_meals.py @@ -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]