From 8bc4f1277002e6ba85458510e731c03b136ec786 Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 14:42:56 +1100 Subject: [PATCH] feat(v2): add household-scoped GET /shopping/{listId} with tests --- api/shopping_v2.py | 40 ++++++++- backend-spec.md | 3 + tests/test_shopping_list_by_id_v2.py | 116 +++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 tests/test_shopping_list_by_id_v2.py diff --git a/api/shopping_v2.py b/api/shopping_v2.py index 27f29ad..090bcd0 100644 --- a/api/shopping_v2.py +++ b/api/shopping_v2.py @@ -3,11 +3,17 @@ from __future__ import annotations from typing import Dict import aiosqlite -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request, Response import shopping -from api.deps import get_db, get_household_from_slug -from api.shopping import CurrentShoppingList, _to_ingredient_item, _to_meal_item, _to_shopping_list_out +from api.deps import error_response, get_db, get_household_from_slug +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"]) @@ -57,3 +63,31 @@ async def get_current_shopping_list_scoped( ingredients_lookup=ingredients_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, + ) diff --git a/backend-spec.md b/backend-spec.md index 97362cc..585ae58 100644 --- a/backend-spec.md +++ b/backend-spec.md @@ -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) 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. diff --git a/tests/test_shopping_list_by_id_v2.py b/tests/test_shopping_list_by_id_v2.py new file mode 100644 index 0000000..caeb55a --- /dev/null +++ b/tests/test_shopping_list_by_id_v2.py @@ -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", "")