From 482666907bbed8590a9dcaeb4163d1111ce2c5a6 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 13:51:50 -0700 Subject: [PATCH] fix(meal-planner): validate meal_type path param, enforce store whitelist safety, add week_start date validation, make PrepTask frozen - upsert_slot: raise 422 immediately if meal_type not in VALID_MEAL_TYPES - update_prep_task: assert whitelist safety contract after dict comprehension - CreatePlanRequest: week_start typed as date with must_be_monday validator; str() cast at call site - PrepTask: frozen=True; build_prep_tasks rewired to use (priority, kwargs) tuples so frozen instances are built with correct sequence_order in one pass (no post-construction mutation) - Move deferred import json to file-level in meal_plans.py - Fix test dates: "2026-04-14" was a Tuesday; updated request bodies to "2026-04-13" (Monday) --- app/api/endpoints/meal_plans.py | 6 ++-- app/db/store.py | 1 + app/models/schemas/meal_plan.py | 13 +++++++-- app/services/meal_plan/prep_scheduler.py | 37 ++++++++++++++---------- tests/api/test_meal_plans.py | 8 ++--- 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/app/api/endpoints/meal_plans.py b/app/api/endpoints/meal_plans.py index f1bf3f8..eaed30f 100644 --- a/app/api/endpoints/meal_plans.py +++ b/app/api/endpoints/meal_plans.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json from datetime import date from fastapi import APIRouter, Depends, HTTPException @@ -48,7 +49,6 @@ def _slot_summary(row: dict) -> SlotSummary: def _plan_summary(plan: dict, slots: list[dict]) -> PlanSummary: meal_types = plan.get("meal_types") or ["dinner"] if isinstance(meal_types, str): - import json meal_types = json.loads(meal_types) return PlanSummary( id=plan["id"], @@ -87,7 +87,7 @@ async def create_plan( else: meal_types = ["dinner"] - plan = await asyncio.to_thread(store.create_meal_plan, req.week_start, meal_types) + plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types) slots = await asyncio.to_thread(store.get_plan_slots, plan["id"]) return _plan_summary(plan, slots) @@ -124,6 +124,8 @@ async def upsert_slot( session: CloudUser = Depends(get_session), store: Store = Depends(get_store), ) -> SlotSummary: + if meal_type not in VALID_MEAL_TYPES: + raise HTTPException(status_code=422, detail=f"Invalid meal_type '{meal_type}'.") plan = await asyncio.to_thread(store.get_meal_plan, plan_id) if plan is None: raise HTTPException(status_code=404, detail="Plan not found.") diff --git a/app/db/store.py b/app/db/store.py index 0701b3a..83dd49a 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1115,6 +1115,7 @@ class Store: def update_prep_task(self, task_id: int, **kwargs: object) -> dict | None: allowed = {"duration_minutes", "sequence_order", "notes", "equipment"} updates = {k: v for k, v in kwargs.items() if k in allowed and v is not None} + assert all(k in allowed for k in updates), f"Unexpected column(s): {set(updates) - allowed}" if not updates: return self._fetch_one("SELECT * FROM prep_tasks WHERE id = ?", (task_id,)) set_clause = ", ".join(f"{k} = ?" for k in updates) diff --git a/app/models/schemas/meal_plan.py b/app/models/schemas/meal_plan.py index 9adff0c..6e1a678 100644 --- a/app/models/schemas/meal_plan.py +++ b/app/models/schemas/meal_plan.py @@ -2,16 +2,25 @@ """Pydantic schemas for meal planning endpoints.""" from __future__ import annotations -from pydantic import BaseModel, Field +from datetime import date as _date + +from pydantic import BaseModel, Field, field_validator VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"} class CreatePlanRequest(BaseModel): - week_start: str # ISO date string, e.g. "2026-04-14" (must be Monday) + week_start: _date meal_types: list[str] = Field(default_factory=lambda: ["dinner"]) + @field_validator("week_start") + @classmethod + def must_be_monday(cls, v: _date) -> _date: + if v.weekday() != 0: + raise ValueError("week_start must be a Monday (weekday 0)") + return v + class UpsertSlotRequest(BaseModel): recipe_id: int | None = None diff --git a/app/services/meal_plan/prep_scheduler.py b/app/services/meal_plan/prep_scheduler.py index 6d3088d..e627f65 100644 --- a/app/services/meal_plan/prep_scheduler.py +++ b/app/services/meal_plan/prep_scheduler.py @@ -6,13 +6,13 @@ Pure function — no DB or network calls. Sorts tasks by equipment priority """ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass _EQUIPMENT_PRIORITY = {"oven": 0, "stovetop": 1, "cold": 2, "no-heat": 3} _DEFAULT_PRIORITY = 4 -@dataclass +@dataclass(frozen=True) class PrepTask: recipe_id: int | None slot_id: int | None @@ -57,7 +57,7 @@ def build_prep_tasks(slots: list[dict], recipes: list[dict]) -> list[PrepTask]: return [] recipe_map: dict[int, dict] = {r["id"]: r for r in recipes} - raw_tasks: list[tuple[int, PrepTask]] = [] # (priority, task) + raw_tasks: list[tuple[int, dict]] = [] # (priority, kwargs) for slot in slots: recipe_id = slot.get("recipe_id") @@ -69,18 +69,23 @@ def build_prep_tasks(slots: list[dict], recipes: list[dict]) -> list[PrepTask]: eq = _equipment(recipe) priority = _EQUIPMENT_PRIORITY.get(eq or "", _DEFAULT_PRIORITY) - task = PrepTask( - recipe_id=recipe_id, - slot_id=slot.get("id"), - task_label=recipe.get("name", f"Recipe {recipe_id}"), - duration_minutes=_total_minutes(recipe), - sequence_order=0, # filled below - equipment=eq, - ) - raw_tasks.append((priority, task)) + raw_tasks.append((priority, { + "recipe_id": recipe_id, + "slot_id": slot.get("id"), + "task_label": recipe.get("name", f"Recipe {recipe_id}"), + "duration_minutes": _total_minutes(recipe), + "equipment": eq, + })) raw_tasks.sort(key=lambda t: t[0]) - for i, (_, task) in enumerate(raw_tasks, 1): - task.sequence_order = i - - return [t for _, t in raw_tasks] + return [ + PrepTask( + recipe_id=kw["recipe_id"], + slot_id=kw["slot_id"], + task_label=kw["task_label"], + duration_minutes=kw["duration_minutes"], + sequence_order=i, + equipment=kw["equipment"], + ) + for i, (_, kw) in enumerate(raw_tasks, 1) + ] diff --git a/tests/api/test_meal_plans.py b/tests/api/test_meal_plans.py index 0cea609..176f0c4 100644 --- a/tests/api/test_meal_plans.py +++ b/tests/api/test_meal_plans.py @@ -69,20 +69,20 @@ def paid_session(): def test_create_plan_free_tier_locks_to_dinner(free_session): resp = client.post("/api/v1/meal-plans/", json={ - "week_start": "2026-04-14", "meal_types": ["breakfast", "dinner"] + "week_start": "2026-04-13", "meal_types": ["breakfast", "dinner"] }) assert resp.status_code == 200 # Free tier forced to dinner-only regardless of request - free_session.create_meal_plan.assert_called_once_with("2026-04-14", ["dinner"]) + free_session.create_meal_plan.assert_called_once_with("2026-04-13", ["dinner"]) def test_create_plan_paid_tier_respects_meal_types(paid_session): resp = client.post("/api/v1/meal-plans/", json={ - "week_start": "2026-04-14", "meal_types": ["breakfast", "lunch", "dinner"] + "week_start": "2026-04-13", "meal_types": ["breakfast", "lunch", "dinner"] }) assert resp.status_code == 200 paid_session.create_meal_plan.assert_called_once_with( - "2026-04-14", ["breakfast", "lunch", "dinner"] + "2026-04-13", ["breakfast", "lunch", "dinner"] )