feat(meals-v2): add household-scoped “mark consumed” endpoint with timezone validation and request cleanup; tests and spec updated; regenerate OpenAPI

This commit is contained in:
jableader 2025-11-01 15:21:01 +11:00
parent 746a327d7d
commit ec3199fb0c
4 changed files with 257 additions and 2 deletions

View file

@ -7,13 +7,18 @@ import aiosqlite
from fastapi import APIRouter, Depends, Query, Request, Response from fastapi import APIRouter, Depends, Query, Request, Response
import meals import meals
from common import ProblemDetails import shopping
from common import ProblemDetails, ApiModel
from api.deps import error_response from api.deps import error_response
from api.deps import get_db, get_household_from_slug from api.deps import get_db, get_household_from_slug
router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"]) router = APIRouter(prefix="/households/{householdSlug}/meals", tags=["meals-v2"])
class MarkConsumedBody(ApiModel):
consumed_date: Optional[datetime.datetime] = None
@router.get( @router.get(
"/upcoming", "/upcoming",
operation_id="getUpcomingMealsV2", operation_id="getUpcomingMealsV2",
@ -81,3 +86,39 @@ async def get_meal_scoped(
if not meal: if not meal:
return error_response(request, 404, "Meal not found") return error_response(request, 404, "Meal not found")
return meal return meal
@router.post(
"/{meal_id}/consumed",
operation_id="markMealConsumedV2",
summary="Mark a meal as consumed (scoped)",
response_model=meals.Meal,
responses={
400: {"model": ProblemDetails},
404: {"model": ProblemDetails},
},
)
async def mark_meal_consumed_scoped(
meal_id: int,
request: Request,
body: Optional[MarkConsumedBody] = None,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> meals.Meal | Response:
hid = household["id"]
consumed_date: Optional[datetime.datetime] = None
if body is not None:
# Model aliasing handles consumedDate -> consumed_date
consumed_date = getattr(body, "consumed_date", None)
if consumed_date is not None and not getattr(consumed_date, "tzinfo", None):
return error_response(request, 400, "Consumed date must include timezone")
meal = await meals.find_meal_by_id_scoped(conn, meal_id, hid)
if not meal:
return error_response(request, 404, "Meal not found")
await meals.mark_consumed(conn, meal, consumed_date or datetime.datetime.now().astimezone())
# Clear any outstanding meal request entries for this meal
await shopping.remove_request(conn, person=None, meal=meal)
return meal

View file

@ -1,5 +1,7 @@
## 0.4 Validated behaviors and invariants ## 0.4 Validated behaviors and invariants
- GET `/api/v1/households/{householdSlug}/shopping/{listId}` returning purchased list + lookups scoped to household; cross-household returns 404. - 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 (PASS).
# Backend Specification: Household Multi-Tenancy (v2) # Backend Specification: Household Multi-Tenancy (v2)
@ -227,7 +229,8 @@ Impact on existing routes (exact files to refactor):
- ✅ Meals (partial): Added `api/meals_v2.py` with: - ✅ Meals (partial): Added `api/meals_v2.py` with:
- `/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. - `POST /api/v1/households/{householdSlug}/meals/{id}/consumed` marks consumed with optional `consumedDate` (requires timezone if provided); removes outstanding meal requests; all operations scoped to household.
- Repository functions `find_upcoming_meals_by_date_range_scoped` and `find_meal_by_id_scoped` added. Tests verify isolation and consumed behavior.
- ✅ Shopping (partial): - ✅ Shopping (partial):
- Added `api/shopping_v2.py` with: - Added `api/shopping_v2.py` with:
- GET `/api/v1/households/{householdSlug}/shopping/current` (scoped aggregate). - GET `/api/v1/households/{householdSlug}/shopping/current` (scoped aggregate).

View file

@ -2004,6 +2004,89 @@
] ]
} }
}, },
"/api/v1/households/{householdSlug}/meals/{meal_id}/consumed": {
"post": {
"tags": [
"v2",
"meals-v2"
],
"summary": "Mark a meal as consumed (scoped)",
"operationId": "markMealConsumedV2",
"parameters": [
{
"name": "meal_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Meal Id"
}
},
{
"name": "householdSlug",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Householdslug"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/MarkConsumedBody"
},
{
"type": "null"
}
],
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Meal-Output"
}
}
}
},
"400": {
"$ref": "#/components/responses/Problem400"
},
"404": {
"$ref": "#/components/responses/Problem404"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
},
"403": {
"$ref": "#/components/responses/Problem403"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"/api/v1/households/{householdSlug}/shopping/current": { "/api/v1/households/{householdSlug}/shopping/current": {
"get": { "get": {
"tags": [ "tags": [
@ -2577,6 +2660,24 @@
], ],
"title": "ListIngredientItem" "title": "ListIngredientItem"
}, },
"MarkConsumedBody": {
"properties": {
"consumedDate": {
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "null"
}
],
"title": "Consumeddate"
}
},
"type": "object",
"title": "MarkConsumedBody"
},
"Meal-Input": { "Meal-Input": {
"properties": { "properties": {
"id": { "id": {

View file

@ -0,0 +1,110 @@
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 TestMealsConsumedV2(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 two meals in different households
await self.conn.execute(
"INSERT INTO Meal (suggested_date, household_id) VALUES (?, ?)",
(datetime.datetime.now().astimezone().isoformat(), self.h1_id),
)
async with self.conn.execute("SELECT last_insert_rowid()") as c:
row = await c.fetchone()
assert row is not None
self.meal_h1 = int(row[0])
await self.conn.execute(
"INSERT INTO Meal (suggested_date, household_id) VALUES (?, ?)",
(datetime.datetime.now().astimezone().isoformat(), self.h2_id),
)
async with self.conn.execute("SELECT last_insert_rowid()") as c:
row = await c.fetchone()
assert row is not None
self.meal_h2 = int(row[0])
# Seed outstanding meal requests in both households
await self.conn.execute(
"INSERT INTO ShoppingListItem (ingredient_id, meal_id, person_id, created_date, household_id) VALUES (NULL, ?, 1, datetime('now'), ?)",
(self.meal_h1, self.h1_id),
)
await self.conn.execute(
"INSERT INTO ShoppingListItem (ingredient_id, meal_id, person_id, created_date, household_id) VALUES (NULL, ?, 1, datetime('now'), ?)",
(self.meal_h2, self.h2_id),
)
await self.conn.commit()
async def asyncTearDown(self):
await self.conn.close()
main.app.dependency_overrides.clear()
def test_mark_consumed_scoped(self):
body = {"consumedDate": datetime.datetime.now().astimezone().isoformat()}
r = self.client.post(
f"/api/v1/households/{self.h1}/meals/{self.meal_h1}/consumed",
headers=self.headers,
json=body,
)
assert r.status_code == 200, r.text
meal = r.json()
assert meal["id"] == self.meal_h1
assert meal["consumedDate"] is not None
# H1 meal request should be gone; H2 remains
r1 = self.client.get(
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
)
assert r1.status_code == 200
cur1 = r1.json()
assert all(i.get("mealId") != self.meal_h1 for i in cur1["requestedMeals"]) # none for h1
r2 = self.client.get(
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
)
assert r2.status_code == 200
cur2 = r2.json()
assert any(i.get("mealId") == self.meal_h2 for i in cur2["requestedMeals"]) # still present