feat(v2): add household-scoped shopping purchase endpoint with tests

This commit is contained in:
jableader 2025-11-01 14:49:30 +11:00
parent 8bc4f12770
commit 3c69355a5c
5 changed files with 296 additions and 2 deletions

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Dict from typing import Dict, List
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends, Request, Response from fastapi import APIRouter, Depends, Request, Response
@ -13,6 +13,8 @@ from api.shopping import (
_to_ingredient_item, _to_ingredient_item,
_to_meal_item, _to_meal_item,
_to_shopping_list_out, _to_shopping_list_out,
IngredientPurchaseItemIn,
PurchaseListIn,
) )
router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping-v2"]) router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping-v2"])
@ -91,3 +93,51 @@ async def get_shopping_list_scoped(
recipes_lookup=recipes_lookup, recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup, ingredients_lookup=ingredients_lookup,
) )
@router.post(
"",
response_model=PurchasedShoppingList,
operation_id="purchaseIngredientsV2",
summary="Purchase ingredients for a shopping list (scoped)",
)
async def purchase_ingredients_scoped(
shopping_list: PurchaseListIn,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
hid = household["id"]
# Map outward input DTO to domain model
domain_items: List[shopping.ShoppingListItem] = []
for it in shopping_list.items:
created = it.created_date or __import__("datetime").datetime.now().astimezone()
domain_items.append(
shopping.ShoppingListItem(
ingredient_id=it.ingredient_id,
person_id=it.person_id,
meal_id=it.meal_id,
recipe_id=it.recipe_id,
created_date=created,
)
)
domain_list = shopping.ShoppingList(items=domain_items, store_name=shopping_list.store_name)
try:
# Use household-scoped purchase which ensures requests belong to the same household
await shopping.purchase_scoped(conn, domain_list, hid)
except ValueError as e:
return error_response(request, 400, str(e))
# Lookups for outward response
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, domain_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(domain_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)

View file

@ -228,7 +228,13 @@ Impact on existing routes (exact files to refactor):
- `/api/v1/households/{householdSlug}/meals/upcoming` filtering by `household_id`. - `/api/v1/households/{householdSlug}/meals/upcoming` filtering by `household_id`.
- `/api/v1/households/{householdSlug}/meals/{id}` returns 404 across households. - `/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. - 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. - ✅ Shopping (partial):
- Added `api/shopping_v2.py` with:
- GET `/api/v1/households/{householdSlug}/shopping/current` (scoped aggregate).
- GET `/api/v1/households/{householdSlug}/shopping/{listId}` returning purchased list + lookups scoped to household; cross-household returns 404.
- POST `/api/v1/households/{householdSlug}/shopping` to purchase list items scoped to household; validates invariants and updates outstanding requests.
- Scoped helpers in `shopping/repository.py` and `shopping/__init__.py` filter by `household_id` (find items, load list, purchased ingredients, and purchase_scoped).
- Tests: `tests/test_shopping_household_v2.py` (current isolation), `tests/test_shopping_list_by_id_v2.py` (list-by-id scoping), `tests/test_shopping_purchase_v2.py` (scoped purchase). PASS.
- ⏳ 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 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. - ⏳ 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.

View file

@ -13,6 +13,7 @@ from shopping.repository import (
load_shopping_list as load_shopping_list, load_shopping_list as load_shopping_list,
load_shopping_list_scoped as load_shopping_list_scoped, load_shopping_list_scoped as load_shopping_list_scoped,
purchase as purchase, purchase as purchase,
purchase_scoped as purchase_scoped,
remove_request as remove_request, remove_request as remove_request,
request as request, request as request,
update_purchased_meals as update_purchased_meals, update_purchased_meals as update_purchased_meals,

View file

@ -140,6 +140,118 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
await update_purchased_meals(conn, meal_ids) await update_purchased_meals(conn, meal_ids)
async def purchase_scoped(conn, shopping_list: ShoppingList, household_id: int) -> None:
if shopping_list.purchased_by_id is None or shopping_list.purchased_by_id < 0:
raise ValueError("Shopping list must have a person id")
if shopping_list.items is None or len(shopping_list.items) == 0:
raise ValueError("Shopping list must have items")
from datetime import datetime
shopping_list.created_date = datetime.now().astimezone()
async with conn.execute(
"""
INSERT INTO ShoppingList (created_date, store_name, purchased_by_id, household_id)
VALUES (?, ?, ?, ?)
""",
(
shopping_list.created_date.isoformat(),
shopping_list.store_name,
shopping_list.purchased_by_id,
household_id,
),
) as cursor:
shopping_list.id = cursor.lastrowid
for item in shopping_list.items:
item.list_id = shopping_list.id
validate_request(item)
if item.ingredient_id is None or item.ingredient_id < 0:
raise ValueError("Ingredient request must have a valid ingredient id")
isMeal = item.meal_id is not None and item.meal_id >= 0
isPersonRequest = (not isMeal) and item.person_id is not None and item.person_id >= 0
if not isMeal and not isPersonRequest:
raise ValueError("Ingredient request must have either a meal or a person id")
if isPersonRequest:
# Update existing request from its null id, scoping by household
async with conn.execute(
"""
UPDATE ShoppingListItem
SET list_id = ?
WHERE ingredient_id = ?
AND list_id IS NULL
AND person_id = ?
AND meal_id IS NULL
AND recipe_id IS NULL
AND household_id = ?
""",
(shopping_list.id, item.ingredient_id, item.person_id, household_id),
) as cursor:
if cursor.rowcount == 0:
raise ValueError(
"Ingredient request must have a valid person id and ingredient id"
)
elif isMeal:
# Insert new request for meal
if item.meal_id is None or item.meal_id < 0:
raise ValueError("Meal request must have a valid meal id")
async with conn.execute(
"""
INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, meal_id, recipe_id, created_date, household_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
item.ingredient_id,
shopping_list.id,
item.person_id,
item.meal_id,
item.recipe_id,
item.created_date.isoformat(),
household_id,
),
) as cursor:
item.id = cursor.lastrowid
meal_ids = list(
{
item.meal_id
for item in shopping_list.items
if item.meal_id is not None and item.meal_id >= 0
}
)
# Use scoped purchased ingredient lookup for meal purchase auto-update
if meal_ids:
# Mark purchased if all ingredients covered in household
from meals.repository import find_meal_by_id, mark_purchased
purchased_ingredient_ids = {
item.ingredient_id
async for item in get_purchased_ingredients_scoped(conn, meal_ids, household_id)
}
for meal_id in meal_ids:
meal = await find_meal_by_id(conn, meal_id)
if not meal:
continue
ingredients = {
ingredient.id
for mr in meal.recipes
for ingredient in (mr.recipe.ingredients if mr.recipe else [])
} | {ingredient.id for ingredient in meal.extra_ingredients}
remaining_ingredients = ingredients - purchased_ingredient_ids
if not remaining_ingredients:
await mark_purchased(conn, meal)
await remove_request(conn, person=None, meal=meal)
async def update_purchased_meals(conn, meal_ids: List[int]) -> None: async def update_purchased_meals(conn, meal_ids: List[int]) -> None:
if not meal_ids: if not meal_ids:
return return

View file

@ -0,0 +1,125 @@
import unittest
from fastapi.testclient import TestClient
import main
from db import connect, create
from scripts.migration_to_households import run_migration
class TestShoppingPurchaseV2(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": "s@test.com", "password": "pw", "displayName": "S"},
)
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"]
# Lookup household ids
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])
# Seed ingredients and outstanding requests in both households
# H1 ingredient + request
await self.conn.execute(
"INSERT INTO Ingredient (name, line, unit, quantity, recipe_id, meal_id, household_id) VALUES ('Milk', '1L milk', 'L', 1, NULL, NULL, ?)",
(self.h1_id,),
)
async with self.conn.execute("SELECT last_insert_rowid()") as c:
row = await c.fetchone()
assert row is not None
self.milk_id = int(row[0])
await self.conn.execute(
"INSERT INTO ShoppingListItem (ingredient_id, person_id, created_date, household_id) VALUES (?, 1, datetime('now'), ?)",
(self.milk_id, self.h1_id),
)
# H2 ingredient + request
await self.conn.execute(
"INSERT INTO Ingredient (name, line, unit, quantity, recipe_id, meal_id, household_id) VALUES ('Eggs', '12 eggs', 'dozen', 1, NULL, NULL, ?)",
(self.h2_id,),
)
async with self.conn.execute("SELECT last_insert_rowid()") as c:
row = await c.fetchone()
assert row is not None
self.eggs_id = int(row[0])
await self.conn.execute(
"INSERT INTO ShoppingListItem (ingredient_id, person_id, created_date, household_id) VALUES (?, 1, datetime('now'), ?)",
(self.eggs_id, self.h2_id),
)
await self.conn.commit()
async def asyncTearDown(self):
await self.conn.close()
main.app.dependency_overrides.clear()
def test_purchase_scoped_success(self):
# Purchase the outstanding H1 ingredient
body = {
"storeName": "woolworths",
"items": [
{
"ingredientId": self.milk_id,
"personId": 1,
}
],
}
resp = self.client.post(
f"/api/v1/households/{self.h1}/shopping", headers=self.headers, json=body
)
assert resp.status_code == 200, resp.text
data = resp.json()
assert data["list"]["id"] > 0
assert data["list"]["storeName"] in ("woolworths", "coles", "home")
assert len(data["list"]["items"]) == 1
# Verify via API: H1 has no outstanding items; H2 still has one
r1 = self.client.get(
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
)
assert r1.status_code == 200, r1.text
cur1 = r1.json()
assert len(cur1["outstandingItems"]) == 0
r2 = self.client.get(
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
)
assert r2.status_code == 200, r2.text
cur2 = r2.json()
assert len(cur2["outstandingItems"]) == 1
def test_purchase_validation_error(self):
resp = self.client.post(
f"/api/v1/households/{self.h1}/shopping", headers=self.headers, json={"storeName": "woolworths", "items": []}
)
assert resp.status_code == 400, resp.text
assert "application/problem+json" in resp.headers.get("content-type", "")