From e67790c72d3de02b01854448b722734ba88cc411 Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 14:21:34 +1100 Subject: [PATCH] feat(v2): JWT auth, Invitations API, and meals household scoping with tests --- api/meals_v2.py | 61 ++++++++++++++++++++++ backend-spec.md | 5 +- main.py | 2 + meals/__init__.py | 1 + meals/repository.py | 14 +++++ tests/test_meals_household_v2.py | 89 ++++++++++++++++++++++++++++++++ 6 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 api/meals_v2.py create mode 100644 tests/test_meals_household_v2.py diff --git a/api/meals_v2.py b/api/meals_v2.py new file mode 100644 index 0000000..558a0cc --- /dev/null +++ b/api/meals_v2.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import datetime +from typing import List + +import aiosqlite +from fastapi import APIRouter, Depends, Query + +import meals +from api.deps import get_db, get_household_from_slug + +router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"]) + + +@router.get( + "/upcoming", + operation_id="getUpcomingMealsV2", + summary="List upcoming meals in a date range (scoped)", +) +async def get_upcoming_meals_scoped( + household=Depends(get_household_from_slug), + date_from: datetime.datetime = Query(..., alias="from"), + to: datetime.datetime = Query(...), + conn: aiosqlite.Connection = Depends(get_db), +) -> List[meals.Meal]: + hid = household["id"] + # Use a scoped repository function, falling back to filtering in SQL until repository is fully scoped + try: + async for _ in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid): + # if function exists, break immediately to use it + break + use_scoped = True + except AttributeError: + use_scoped = False + + result: List[meals.Meal] = [] + if use_scoped: + async for meal in meals.find_upcoming_meals_by_date_range_scoped(conn, date_from, to, hid): + result.append(meal) + else: + # Temporary path: direct query with household_id filter + async with conn.execute( + f""" + SELECT {','.join(meals.Meal.KEYS)} FROM Meal + WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ? + """, + (date_from, to, hid), + ) as cursor: + async for row in cursor: + result.append(meals.Meal(**{k: v for k, v in zip(meals.Meal.KEYS, row)})) + + if not result: + return result + + # Load relateds similar to v1 + await meals.bulk_load_participants(conn, result) + for meal in result: + await meals.load_recipes(conn, meal) + await meals.load_extra_ingredients(conn, meal) + + return result diff --git a/backend-spec.md b/backend-spec.md index e169214..0ea4bbf 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -221,8 +221,9 @@ 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). - - ⏳ Update Repositories: meals, ingredients, shopping, products to accept `household_id` and filter accordingly. - - ⏳ Update Routers: move/duplicate existing routers under the household router and wire `household_id` through. + - ✅ 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. + - ⏳ Update Repositories: ingredients, shopping, products to accept `household_id` and filter accordingly; extend meals create/update/consumed/delete with scoping. + - ⏳ Update Routers: move/duplicate remaining routers under the household router and wire `household_id` through. - **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403. - Notes: diff --git a/main.py b/main.py index 81142a4..c5ecea2 100644 --- a/main.py +++ b/main.py @@ -16,6 +16,7 @@ from api import ( products as products_router, recipes as recipes_router, recipes_v2 as recipes_v2_router, + meals_v2 as meals_v2_router, shopping as shopping_router, households as households_router, ) @@ -171,6 +172,7 @@ def create_app() -> FastAPI: except Exception: pass app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["v2"]) # new + app.include_router(meals_v2_router.router, prefix="/api/v1", tags=["v2"]) # new # Routes app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus) diff --git a/meals/__init__.py b/meals/__init__.py index a915d30..db7da0d 100644 --- a/meals/__init__.py +++ b/meals/__init__.py @@ -5,6 +5,7 @@ from meals.repository import ( delete_meal as delete_meal, find_meal_by_id as find_meal_by_id, 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, insert_meal_participant as insert_meal_participant, insert_meal_recipe as insert_meal_recipe, diff --git a/meals/repository.py b/meals/repository.py index 8d57ed2..015f1f8 100644 --- a/meals/repository.py +++ b/meals/repository.py @@ -157,6 +157,20 @@ async def find_upcoming_meals_by_date_range( yield Meal(**{k: v for k, v in zip(Meal.KEYS, row)}) +async def find_upcoming_meals_by_date_range_scoped( + conn, start: datetime.datetime, end: datetime.datetime, household_id: int +) -> AsyncIterator[Meal]: + async with conn.execute( + f""" + SELECT {",".join(Meal.KEYS)} FROM Meal + WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ? + """, + (start, end, household_id), + ) as cursor: + async for row in cursor: + yield Meal(**{k: v for k, v in zip(Meal.KEYS, row)}) + + async def load_participants(conn, meal: Meal) -> None: # Fetch all participant links links: list[tuple[int, str]] = [] diff --git a/tests/test_meals_household_v2.py b/tests/test_meals_household_v2.py new file mode 100644 index 0000000..1de8ea1 --- /dev/null +++ b/tests/test_meals_household_v2.py @@ -0,0 +1,89 @@ +import datetime +import unittest +from fastapi.testclient import TestClient + +import main +from db import connect, create +from scripts.migration_to_households import run_migration + + +class TestMealsHouseholdV2(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.conn = await connect(":memory:") + await create(self.conn) + await run_migration(self.conn) + + async def override_get_db(): + try: + yield self.conn + finally: + pass + + main.app.dependency_overrides[main.get_db] = override_get_db + self.client = TestClient(main.app) + + # Register a user and create two households + r = self.client.post( + "/api/v1/auth/register", + json={"email": "m@test.com", "password": "pw", "displayName": "M"}, + ) + assert r.status_code == 200, r.text + token = r.json()["accessToken"] + self.headers = {"Authorization": f"Bearer {token}"} + + r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H1"}) + assert r.status_code == 200, r.text + self.h1 = r.json()["slug"] + r = self.client.post("/api/v1/households", headers=self.headers, json={"name": "H2"}) + assert r.status_code == 200, r.text + self.h2 = r.json()["slug"] + + # Insert meals directly with household_id to seed data + now = datetime.datetime.utcnow() + # Raw inserts + async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h1,)) as c: + row = await c.fetchone() + assert row is not None + self.h1_id = int(row[0]) + async with self.conn.execute("SELECT id FROM Household WHERE slug = ?", (self.h2,)) as c: + row = await c.fetchone() + assert row is not None + self.h2_id = int(row[0]) + + await self.conn.execute( + "INSERT INTO Meal (suggested_date, consumed_date, deleted_date, purchase_date, household_id) VALUES (?, NULL, NULL, NULL, ?)", + (now + datetime.timedelta(days=1), self.h1_id), + ) + await self.conn.execute( + "INSERT INTO Meal (suggested_date, consumed_date, deleted_date, purchase_date, household_id) VALUES (?, NULL, NULL, NULL, ?)", + (now + datetime.timedelta(days=2), self.h2_id), + ) + await self.conn.commit() + + async def asyncTearDown(self): + await self.conn.close() + main.app.dependency_overrides.clear() + + def test_upcoming_meals_are_scoped(self): + now = datetime.datetime.utcnow() + params = { + "from": (now - datetime.timedelta(days=1)).isoformat() + "Z", + "to": (now + datetime.timedelta(days=7)).isoformat() + "Z", + } + r1 = self.client.get( + f"/api/v1/households/{self.h1}/meals/upcoming", headers=self.headers, params=params + ) + assert r1.status_code == 200, r1.text + m1 = r1.json() + assert isinstance(m1, list) + assert len(m1) == 1 + + r2 = self.client.get( + f"/api/v1/households/{self.h2}/meals/upcoming", headers=self.headers, params=params + ) + assert r2.status_code == 200, r2.text + m2 = r2.json() + assert isinstance(m2, list) + assert len(m2) == 1 + # Ensure different meal ids per household + assert m1[0]["id"] != m2[0]["id"]