feat(v2): add household-scoped shopping purchase endpoint with tests
This commit is contained in:
parent
8bc4f12770
commit
3c69355a5c
5 changed files with 296 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
from typing import Dict, List
|
||||
|
||||
import aiosqlite
|
||||
from fastapi import APIRouter, Depends, Request, Response
|
||||
|
|
@ -13,6 +13,8 @@ from api.shopping import (
|
|||
_to_ingredient_item,
|
||||
_to_meal_item,
|
||||
_to_shopping_list_out,
|
||||
IngredientPurchaseItemIn,
|
||||
PurchaseListIn,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping-v2"])
|
||||
|
|
@ -91,3 +93,51 @@ async def get_shopping_list_scoped(
|
|||
recipes_lookup=recipes_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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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/{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.
|
||||
- ✅ 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 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.
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from shopping.repository import (
|
|||
load_shopping_list as load_shopping_list,
|
||||
load_shopping_list_scoped as load_shopping_list_scoped,
|
||||
purchase as purchase,
|
||||
purchase_scoped as purchase_scoped,
|
||||
remove_request as remove_request,
|
||||
request as request,
|
||||
update_purchased_meals as update_purchased_meals,
|
||||
|
|
|
|||
|
|
@ -140,6 +140,118 @@ async def purchase(conn, shopping_list: ShoppingList) -> None:
|
|||
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:
|
||||
if not meal_ids:
|
||||
return
|
||||
|
|
|
|||
125
tests/test_shopping_purchase_v2.py
Normal file
125
tests/test_shopping_purchase_v2.py
Normal 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", "")
|
||||
Loading…
Reference in a new issue