feat(v2): add household-scoped GET /shopping/{listId} with tests

This commit is contained in:
jableader 2025-11-01 14:42:56 +11:00
parent bb480d696b
commit 8bc4f12770
3 changed files with 156 additions and 3 deletions

View file

@ -3,11 +3,17 @@ from __future__ import annotations
from typing import Dict from typing import Dict
import aiosqlite import aiosqlite
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Request, Response
import shopping import shopping
from api.deps import get_db, get_household_from_slug from api.deps import error_response, get_db, get_household_from_slug
from api.shopping import CurrentShoppingList, _to_ingredient_item, _to_meal_item, _to_shopping_list_out from api.shopping import (
CurrentShoppingList,
PurchasedShoppingList,
_to_ingredient_item,
_to_meal_item,
_to_shopping_list_out,
)
router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping-v2"]) router = APIRouter(prefix="/households/{householdSlug}/shopping", tags=["shopping-v2"])
@ -57,3 +63,31 @@ async def get_current_shopping_list_scoped(
ingredients_lookup=ingredients_lookup, ingredients_lookup=ingredients_lookup,
recipes_lookup=recipes_lookup, recipes_lookup=recipes_lookup,
) )
@router.get(
"/{list_id}",
response_model=PurchasedShoppingList,
operation_id="getShoppingListV2",
summary="Get a purchased shopping list by id (scoped)",
)
async def get_shopping_list_scoped(
list_id: int,
request: Request,
household=Depends(get_household_from_slug),
conn: aiosqlite.Connection = Depends(get_db),
) -> PurchasedShoppingList | Response:
hid = household["id"]
shopping_list = await shopping.load_shopping_list_scoped(conn, list_id, hid)
if not shopping_list:
return error_response(request, 404, "Shopping list not found")
meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups(
conn, shopping_list.items
)
return PurchasedShoppingList(
list=_to_shopping_list_out(shopping_list),
meals_lookup=meals_lookup,
recipes_lookup=recipes_lookup,
ingredients_lookup=ingredients_lookup,
)

View file

@ -1,3 +1,6 @@
## 0.4 Validated behaviors and invariants
- GET `/api/v1/households/{householdSlug}/shopping/{listId}` returning purchased list + lookups scoped to household; cross-household returns 404.
- 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)
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. 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.

View file

@ -0,0 +1,116 @@
import unittest
from fastapi.testclient import TestClient
import main
from db import connect, create
from scripts.migration_to_households import run_migration
class TestShoppingListByIdV2(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 a purchased shopping list in H1 with one item
await self.conn.execute(
"INSERT INTO ShoppingList (created_date, store_name, purchased_by_id, household_id) VALUES (datetime('now'), '', 1, ?)",
(self.h1_id,),
)
async with self.conn.execute("SELECT last_insert_rowid()") as c:
row = await c.fetchone()
assert row is not None
self.h1_list_id = int(row[0])
# Seed ingredient referenced by item
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
milk_id = int(row[0])
await self.conn.execute(
"INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, created_date, household_id) VALUES (?, ?, 1, datetime('now'), ?)",
(milk_id, self.h1_list_id, self.h1_id),
)
# Seed a purchased shopping list in H2 with one item
await self.conn.execute(
"INSERT INTO ShoppingList (created_date, store_name, purchased_by_id, household_id) VALUES (datetime('now'), '', 1, ?)",
(self.h2_id,),
)
async with self.conn.execute("SELECT last_insert_rowid()") as c:
row = await c.fetchone()
assert row is not None
self.h2_list_id = int(row[0])
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
eggs_id = int(row[0])
await self.conn.execute(
"INSERT INTO ShoppingListItem (ingredient_id, list_id, person_id, created_date, household_id) VALUES (?, ?, 1, datetime('now'), ?)",
(eggs_id, self.h2_list_id, self.h2_id),
)
await self.conn.commit()
async def asyncTearDown(self):
await self.conn.close()
main.app.dependency_overrides.clear()
def test_get_list_scoped_ok_and_404(self):
# In-scope fetch
resp_ok = self.client.get(
f"/api/v1/households/{self.h1}/shopping/{self.h1_list_id}", headers=self.headers
)
assert resp_ok.status_code == 200, resp_ok.text
body = resp_ok.json()
assert body["list"]["id"] == self.h1_list_id
assert len(body["list"]["items"]) == 1
# Cross-household should 404
resp_404 = self.client.get(
f"/api/v1/households/{self.h1}/shopping/{self.h2_list_id}", headers=self.headers
)
assert resp_404.status_code == 404, resp_404.text
assert "application/problem+json" in resp_404.headers.get("content-type", "")