From bb480d696b166263114fad170fb92d2ea0d7cd60 Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 14:30:45 +1100 Subject: [PATCH] =?UTF-8?q?feat(v2):=20expand=20household=20scoping=20?= =?UTF-8?q?=E2=80=94=20meals=20get-by-id=20and=20shopping=20current,=20wit?= =?UTF-8?q?h=20tests;=20spec=20updated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/meals_v2.py | 25 +++++++++++++++++++++++-- backend-spec.md | 5 ++++- meals/__init__.py | 1 + meals/repository.py | 19 +++++++++++++++++++ tests/test_meals_household_v2.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 75 insertions(+), 3 deletions(-) diff --git a/api/meals_v2.py b/api/meals_v2.py index 558a0cc..27296ad 100644 --- a/api/meals_v2.py +++ b/api/meals_v2.py @@ -1,12 +1,14 @@ from __future__ import annotations import datetime -from typing import List +from typing import List, Optional import aiosqlite -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request, Response import meals +from common import ProblemDetails +from api.deps import error_response from api.deps import get_db, get_household_from_slug router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"]) @@ -59,3 +61,22 @@ async def get_upcoming_meals_scoped( await meals.load_extra_ingredients(conn, meal) return result + + +@router.get( + "/{meal_id}", + operation_id="getMealV2", + summary="Get a meal by id (scoped)", + responses={404: {"model": ProblemDetails}}, +) +async def get_meal_scoped( + meal_id: int, + request: Request, + household=Depends(get_household_from_slug), + conn: aiosqlite.Connection = Depends(get_db), +) -> meals.Meal | Response: + hid = household["id"] + meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid) + if not meal: + return error_response(request, 404, "Meal not found") + return meal diff --git a/backend-spec.md b/backend-spec.md index 4a2e100..97362cc 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -221,7 +221,10 @@ Impact on existing routes (exact files to refactor): - Added `api/recipes_v2.py` providing `/api/v1/households/{householdSlug}/recipes` with list/get/create. - Added scoped repo helpers in `recipes/repository.py` and exported via `recipes/__init__.py`. - Tests in `tests/test_recipes_household_v2.py` validate isolation across households (PASS). - - ✅ Meals (partial): Added `api/meals_v2.py` with `/api/v1/households/{householdSlug}/meals/upcoming` filtering by `household_id`; repository function `find_upcoming_meals_by_date_range_scoped` added. Test `tests/test_meals_household_v2.py` verifies isolation. + - ✅ Meals (partial): Added `api/meals_v2.py` with: + - `/api/v1/households/{householdSlug}/meals/upcoming` filtering by `household_id`. + - `/api/v1/households/{householdSlug}/meals/{id}` returns 404 across households. + - Repository functions `find_upcoming_meals_by_date_range_scoped` and `find_meal_by_id_scoped` added. Tests verify isolation. - ✅ Shopping (partial): Added `api/shopping_v2.py` with `/api/v1/households/{householdSlug}/shopping/current`; added scoped helpers in `shopping/repository.py` and `shopping/__init__.py` to filter by `household_id`. Test `tests/test_shopping_household_v2.py` verifies isolation of outstanding items. - ⏳ Update Repositories: ingredients, products to accept `household_id` and filter accordingly; extend meals create/update/consumed/delete and shopping write flows (purchase, requests) with scoping. - ⏳ Update Routers: move/duplicate remaining routers under the household router and wire `household_id` through. diff --git a/meals/__init__.py b/meals/__init__.py index db7da0d..a5651c1 100644 --- a/meals/__init__.py +++ b/meals/__init__.py @@ -4,6 +4,7 @@ from meals.repository import ( create as create, delete_meal as delete_meal, find_meal_by_id as find_meal_by_id, + find_meal_by_id_scoped as find_meal_by_id_scoped, find_upcoming_meals_by_date_range as find_upcoming_meals_by_date_range, find_upcoming_meals_by_date_range_scoped as find_upcoming_meals_by_date_range_scoped, insert_meal as insert_meal, diff --git a/meals/repository.py b/meals/repository.py index 015f1f8..60de5db 100644 --- a/meals/repository.py +++ b/meals/repository.py @@ -143,6 +143,25 @@ async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]: return None +async def find_meal_by_id_scoped(conn, meal_id: int, household_id: int) -> Optional[Meal]: + async with conn.execute( + f""" + SELECT {",".join(Meal.KEYS)} FROM Meal + WHERE id = ? AND household_id = ? + LIMIT 1 + """, + (meal_id, household_id), + ) as cursor: + async for row in cursor: + meal = Meal(**{k: v for k, v in zip(Meal.KEYS, row)}) + + await load_participants(conn, meal) + await load_recipes(conn, meal) + await load_extra_ingredients(conn, meal) + return meal + return None + + async def find_upcoming_meals_by_date_range( conn, start: datetime.datetime, end: datetime.datetime ) -> AsyncIterator[Meal]: diff --git a/tests/test_meals_household_v2.py b/tests/test_meals_household_v2.py index 1de8ea1..a38d2d4 100644 --- a/tests/test_meals_household_v2.py +++ b/tests/test_meals_household_v2.py @@ -60,6 +60,20 @@ class TestMealsHouseholdV2(unittest.IsolatedAsyncioTestCase): ) await self.conn.commit() + # Capture meal ids for each household + async with self.conn.execute( + "SELECT id FROM Meal WHERE household_id = ? ORDER BY id LIMIT 1", (self.h1_id,) + ) as c: + row = await c.fetchone() + assert row is not None + self.meal_h1_id = int(row[0]) + async with self.conn.execute( + "SELECT id FROM Meal WHERE household_id = ? ORDER BY id LIMIT 1", (self.h2_id,) + ) as c: + row = await c.fetchone() + assert row is not None + self.meal_h2_id = int(row[0]) + async def asyncTearDown(self): await self.conn.close() main.app.dependency_overrides.clear() @@ -87,3 +101,17 @@ class TestMealsHouseholdV2(unittest.IsolatedAsyncioTestCase): assert len(m2) == 1 # Ensure different meal ids per household assert m1[0]["id"] != m2[0]["id"] + + def test_get_meal_scoped(self): + # Correct household should succeed + r_ok = self.client.get( + f"/api/v1/households/{self.h1}/meals/{self.meal_h1_id}", headers=self.headers + ) + assert r_ok.status_code == 200, r_ok.text + assert r_ok.json()["id"] == self.meal_h1_id + + # Cross-household should 404 + r_404 = self.client.get( + f"/api/v1/households/{self.h2}/meals/{self.meal_h1_id}", headers=self.headers + ) + assert r_404.status_code == 404