feat(v2): JWT auth, Invitations API, and meals household scoping with tests

This commit is contained in:
jableader 2025-11-01 14:21:34 +11:00
parent e69299628a
commit e67790c72d
6 changed files with 170 additions and 2 deletions

61
api/meals_v2.py Normal file
View file

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

View file

@ -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 `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).
- ⏳ Update Repositories: meals, ingredients, shopping, products to accept `household_id` and filter accordingly. - ✅ 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 Routers: move/duplicate existing routers under the household router and wire `household_id` through. - ⏳ 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. - **Acceptance**: The same queries as v1, when run under different household slugs and users, return isolated data sets; cross-household access yields 403.
- Notes: - Notes:

View file

@ -16,6 +16,7 @@ from api import (
products as products_router, products as products_router,
recipes as recipes_router, recipes as recipes_router,
recipes_v2 as recipes_v2_router, recipes_v2 as recipes_v2_router,
meals_v2 as meals_v2_router,
shopping as shopping_router, shopping as shopping_router,
households as households_router, households as households_router,
) )
@ -171,6 +172,7 @@ def create_app() -> FastAPI:
except Exception: except Exception:
pass pass
app.include_router(recipes_v2_router.router, prefix="/api/v1", tags=["v2"]) # new 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 # Routes
app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus) app.add_api_route("/healthz", healthz, methods=["GET"], response_model=HealthStatus)

View file

@ -5,6 +5,7 @@ from meals.repository import (
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_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,
insert_meal as insert_meal, insert_meal as insert_meal,
insert_meal_participant as insert_meal_participant, insert_meal_participant as insert_meal_participant,
insert_meal_recipe as insert_meal_recipe, insert_meal_recipe as insert_meal_recipe,

View file

@ -157,6 +157,20 @@ async def find_upcoming_meals_by_date_range(
yield Meal(**{k: v for k, v in zip(Meal.KEYS, row)}) 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: async def load_participants(conn, meal: Meal) -> None:
# Fetch all participant links # Fetch all participant links
links: list[tuple[int, str]] = [] links: list[tuple[int, str]] = []

View file

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