feat(v2): JWT auth, Invitations API, and meals household scoping with tests
This commit is contained in:
parent
e69299628a
commit
e67790c72d
6 changed files with 170 additions and 2 deletions
61
api/meals_v2.py
Normal file
61
api/meals_v2.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
2
main.py
2
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]] = []
|
||||
|
|
|
|||
89
tests/test_meals_household_v2.py
Normal file
89
tests/test_meals_household_v2.py
Normal 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"]
|
||||
Loading…
Reference in a new issue