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
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

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 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.

View file

@ -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,

View file

@ -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]:

View file

@ -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