feat(v2): expand household scoping — meals get-by-id and shopping current, with tests; spec updated
This commit is contained in:
parent
6105d5dadf
commit
bb480d696b
5 changed files with 75 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue