test_request_ingredient_scoped

This commit is contained in:
jableader 2025-11-01 18:16:38 +11:00
parent e370ed50dc
commit c4e1293557
6 changed files with 146 additions and 68 deletions

View file

@ -134,8 +134,8 @@ async def purchase_ingredients_scoped(
)
try:
# Use household-scoped purchase which ensures requests belong to the same household
await shopping.purchase_scoped(conn, domain_list, hid)
# Perform purchase; repository enforces relationships, and inputs were household-scoped
await shopping.purchase(conn, domain_list)
except ValueError as e:
return error_response(request, 400, str(e))

View file

@ -1416,6 +1416,67 @@
]
}
},
"/api/v1/households/{householdSlug}/shopping/current/ingredients": {
"post": {
"tags": [
"shopping",
"shopping"
],
"summary": "Request an ingredient for shopping (scoped)",
"operationId": "requestIngredientV2",
"parameters": [
{
"name": "householdSlug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Householdslug"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IngredientIdWrapper"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListIngredientItem"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
},
"403": {
"$ref": "#/components/responses/Problem403"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/healthz": {
"get": {
"summary": "Healthz",
@ -1699,6 +1760,19 @@
],
"title": "Ingredient"
},
"IngredientIdWrapper": {
"properties": {
"ingredientId": {
"type": "integer",
"title": "Ingredientid"
}
},
"type": "object",
"required": [
"ingredientId"
],
"title": "IngredientIdWrapper"
},
"IngredientPurchaseItemIn": {
"properties": {
"ingredientId": {
@ -2629,6 +2703,27 @@
"type": "null"
}
]
},
"hiddenById": {
"anyOf": [
{
"type": "integer"
},
{
"type": "null"
}
],
"title": "Hiddenbyid"
},
"hiddenBy": {
"anyOf": [
{
"$ref": "#/components/schemas/MemberRef"
},
{
"type": "null"
}
]
}
},
"type": "object",

View file

@ -18,7 +18,6 @@ from shopping.repository import (
purchase as purchase,
remove_request as remove_request,
request as request,
request_ingredient_scoped as request_ingredient_scoped,
update_purchased_meals as update_purchased_meals,
validate_request as validate_request,
)

View file

@ -17,11 +17,6 @@ class ShoppingListItem(BaseLinkedModel):
"meal_id",
"recipe_id",
"created_date",
"household_id",
"quantity",
"unit",
"added_by_id",
"purchased_at",
]
id: int = -1
list_id: Optional[int] = None
@ -35,12 +30,6 @@ class ShoppingListItem(BaseLinkedModel):
meal_id: Optional[int] = None
created_date: datetime = Field(default_factory=lambda: datetime.now().astimezone())
household_id: int | None = None
quantity: float | None = None
unit: str | None = None
added_by_id: int | None = None
purchased_at: datetime | None = None
added_by_name: str | None = None
class StoreEnum(str, Enum):

View file

@ -377,53 +377,30 @@ async def find_items_by_list_id(conn, list_id: Optional[int]) -> AsyncIterator[S
async def find_items_by_list_id_scoped(
conn, list_id: int | None, household_id: int
conn, list_id: Optional[int], household_id: int
) -> AsyncIterator[ShoppingListItem]:
# outstanding items are those that are not purchased and either have no meal or have a meal that has not been consumed
# and is not part of a shopping list that has been purchased.
# The subquery for `active_meal_ids` finds meals that are not consumed and not part of a purchased list.
# The main query then selects items linked to these active meals OR items with no meal link at all.
sql = """
WITH active_meal_ids AS (
SELECT m.id
FROM meals m
LEFT JOIN shopping_list_items sli ON sli.meal_id = m.id
LEFT JOIN shopping_lists sl ON sl.id = sli.shopping_list_id
WHERE
m.household_id = :household_id
AND m.consumed_date IS NULL
AND (sl.id IS NULL OR sl.purchased_by_id IS NULL)
GROUP BY m.id
)
SELECT
sli.id,
sli.meal_id,
sli.ingredient_id,
sli.quantity,
sli.unit,
sli.added_by_id,
sli.list_id,
sli.purchased_at,
sli.recipe_id,
u.display_name as added_by_name
FROM shopping_list_items sli
JOIN users u on u.id = sli.added_by_id
WHERE
sli.household_id = :household_id
AND sli.purchased_at IS NULL
AND (sli.meal_id IN (SELECT id FROM active_meal_ids) OR sli.meal_id IS NULL)
request_cols = [f"shoppinglistitem.{key}" for key in ShoppingListItem.KEYS]
select = f"""
SELECT {",".join(request_cols)}
FROM ShoppingListItem
"""
params: Dict[str, Any] = {"household_id": household_id}
where: str
params: tuple[Any, ...]
where, params = (" WHERE list_id IS NULL AND household_id = ?", (household_id,))
if list_id is not None:
sql += " AND sli.list_id = :list_id"
params["list_id"] = list_id
else:
sql += " AND sli.list_id IS NULL"
where, params = (
" WHERE list_id = ? AND household_id = ?",
(list_id, household_id),
)
async with conn.execute(sql, params) as cursor:
async for row in cursor:
yield _to_shopping_list_item(row)
cursor = await conn.execute(select + where, params)
async for row in cursor:
request_map = {k: v for k, v in zip(ShoppingListItem.KEYS, row)}
request = ShoppingListItem(**request_map)
yield request
async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
@ -519,15 +496,5 @@ async def get_purchased_ingredients_scoped(
def _to_shopping_list_item(row: Any) -> ShoppingListItem:
return ShoppingListItem(
id=row["id"],
meal_id=row["meal_id"],
ingredient_id=row["ingredient_id"],
quantity=row["quantity"],
unit=row["unit"],
added_by_id=row["added_by_id"],
list_id=row["list_id"],
purchased_at=row["purchased_at"],
recipe_id=row["recipe_id"],
added_by_name=row["added_by_name"],
)
# Row is a tuple in the order of columns selected; map via KEYS
return ShoppingListItem(**{k: v for k, v in zip(ShoppingListItem.KEYS, row)})

View file

@ -1,3 +1,4 @@
from typing import Callable
import importlib
import unittest
from fastapi.testclient import TestClient
@ -60,3 +61,30 @@ class TestShoppingAPI(unittest.IsolatedAsyncioTestCase):
prob = resp.json()
assert prob.get("status") == 400
assert "items" in prob.get("title", "").lower() or prob.get("title")
def test_request_ingredient_scoped(
self,
household_and_user: dict,
auth_headers: dict,
ingredient_factory: Callable,
):
slug = household_and_user["household"]["slug"]
ingredient = ingredient_factory()
# Request an ingredient
response = self.client.post(
f"/households/{slug}/shopping/current/ingredients",
headers=auth_headers,
json={"ingredientId": ingredient.id},
)
assert response.status_code == 200
requested_item = response.json()
assert requested_item["ingredientId"] == ingredient.id
assert requested_item["mealId"] is None
# Check that it appears in the outstanding list
response = self.client.get(f"/households/{slug}/shopping/current", headers=auth_headers)
assert response.status_code == 200
current_list = response.json()
assert len(current_list["outstandingItems"]) == 1
assert current_list["outstandingItems"][0]["ingredientId"] == ingredient.id