feat(v2): finalize scoped meals consumed + fix shopping v2 invariants; refresh JWT route; tests green

This commit is contained in:
jableader 2025-11-01 15:29:48 +11:00
parent ec3199fb0c
commit 0502902ffe
5 changed files with 71 additions and 13 deletions

View file

@ -44,7 +44,7 @@ async def login(
@router.post(
"/refresh",
"/refresh-cookie",
response_model=persons.Person,
operation_id="refresh",
summary="Refresh current user from cookie",

View file

@ -6,7 +6,7 @@ import aiosqlite
from fastapi import APIRouter, Depends, Request, Response
import shopping
from api.deps import error_response, get_db, get_household_from_slug
from api.deps import error_response, get_db, get_household_from_slug, get_current_user
from api.shopping import (
CurrentShoppingList,
PurchasedShoppingList,
@ -105,6 +105,7 @@ async def purchase_ingredients_scoped(
shopping_list: PurchaseListIn,
request: Request,
household=Depends(get_household_from_slug),
user=Depends(get_current_user),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
hid = household["id"]
@ -123,7 +124,9 @@ async def purchase_ingredients_scoped(
)
)
domain_list = shopping.ShoppingList(items=domain_items, store_name=shopping_list.store_name)
domain_list = shopping.ShoppingList(
items=domain_items, store_name=shopping_list.store_name, purchased_by_id=user.id
)
try:
# Use household-scoped purchase which ensures requests belong to the same household

View file

@ -2,7 +2,7 @@
- GET `/api/v1/households/{householdSlug}/shopping/{listId}` returning purchased list + lookups scoped to household; cross-household returns 404.
- POST `/api/v1/households/{householdSlug}/meals/{mealId}/consumed` marks a meal consumed within the household; validates timezone on provided `consumedDate`; clears outstanding meal requests only within that household.
- Tests: `tests/test_meals_consumed_v2.py` validates scoping (requests cleared in same household, unaffected in other household). PASS.
- Tests: `tests/test_shopping_household_v2.py` verifies isolation of outstanding items; `tests/test_shopping_list_by_id_v2.py` verifies list-by-id scoping (PASS).
- Tests: `tests/test_shopping_household_v2.py` verifies isolation of outstanding items; `tests/test_shopping_list_by_id_v2.py` verifies list-by-id scoping; `tests/test_shopping_purchase_v2.py` covers scoped purchase (PASS).
# Backend Specification: Household Multi-Tenancy (v2)
This document has been validated against the current codebase (v1) to ensure the plan captures all required changes. It starts with a concise baseline of what exists today, then details the v2 multi-tenancy/auth refactor with concrete, file-scoped steps and acceptance criteria.
@ -238,6 +238,7 @@ Impact on existing routes (exact files to refactor):
- 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.
- Notes: Normalized Ingredient.preparation to allow NULLs from DB (treated as empty string) to avoid 422 in v2 responses; set purchased_by_id in scoped purchase from JWT user.
- ⏳ 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.

View file

@ -46,3 +46,9 @@ class Ingredient(ApiModel):
except ValueError:
return v
return v
# Backward compatibility: DB may contain NULL preparation; normalize to empty string
@field_validator("preparation", mode="before")
@classmethod
def _normalize_preparation(cls, v: Any) -> Any:
return "" if v is None else v

View file

@ -1321,27 +1321,45 @@
}
}
},
"/api/v1/auth/refresh": {
"/api/v1/auth/refresh-cookie": {
"post": {
"tags": [
"v2",
"auth-v2"
"v1",
"auth"
],
"summary": "Refresh current user from cookie",
"operationId": "refresh",
"parameters": [
{
"name": "user_id",
"in": "cookie",
"required": true,
"schema": {
"type": "integer",
"title": "User Id"
}
}
],
"summary": "Refresh",
"operationId": "refreshV2",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshResponse"
"$ref": "#/components/schemas/Person"
}
}
}
},
"422": {
"$ref": "#/components/responses/Problem422"
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
},
"security": [
@ -1393,6 +1411,36 @@
}
}
},
"/api/v1/auth/refresh": {
"post": {
"tags": [
"v2",
"auth-v2"
],
"summary": "Refresh",
"operationId": "refreshV2",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefreshResponse"
}
}
}
},
"422": {
"$ref": "#/components/responses/Problem422"
}
},
"security": [
{
"cookieAuth": []
}
]
}
},
"/api/v1/auth/logout": {
"post": {
"tags": [