feat(v2): expand household scoping — meals get-by-id and shopping current, with tests; spec updated

This commit is contained in:
jableader 2025-11-01 14:30:45 +11:00
parent 6105d5dadf
commit bb480d696b
5 changed files with 75 additions and 3 deletions

View file

@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
from typing import List from typing import List, Optional
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query, Request, Response
import meals import meals
from common import ProblemDetails
from api.deps import error_response
from api.deps import get_db, get_household_from_slug from api.deps import get_db, get_household_from_slug
router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"]) 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) await meals.load_extra_ingredients(conn, meal)
return result 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

View file

@ -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 `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`. - 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). - 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. - ✅ 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 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. - ⏳ Update Routers: move/duplicate remaining routers under the household router and wire `household_id` through.

View file

@ -4,6 +4,7 @@ from meals.repository import (
create as create, create as create,
delete_meal as delete_meal, delete_meal as delete_meal,
find_meal_by_id as find_meal_by_id, 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 as find_upcoming_meals_by_date_range,
find_upcoming_meals_by_date_range_scoped as find_upcoming_meals_by_date_range_scoped, find_upcoming_meals_by_date_range_scoped as find_upcoming_meals_by_date_range_scoped,
insert_meal as insert_meal, insert_meal as insert_meal,

View file

@ -143,6 +143,25 @@ async def find_meal_by_id(conn, meal_id: int) -> Optional[Meal]:
return None 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( async def find_upcoming_meals_by_date_range(
conn, start: datetime.datetime, end: datetime.datetime conn, start: datetime.datetime, end: datetime.datetime
) -> AsyncIterator[Meal]: ) -> AsyncIterator[Meal]:

View file

@ -60,6 +60,20 @@ class TestMealsHouseholdV2(unittest.IsolatedAsyncioTestCase):
) )
await self.conn.commit() 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): async def asyncTearDown(self):
await self.conn.close() await self.conn.close()
main.app.dependency_overrides.clear() main.app.dependency_overrides.clear()
@ -87,3 +101,17 @@ class TestMealsHouseholdV2(unittest.IsolatedAsyncioTestCase):
assert len(m2) == 1 assert len(m2) == 1
# Ensure different meal ids per household # Ensure different meal ids per household
assert m1[0]["id"] != m2[0]["id"] 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