From 50f908f2047f4122a74e3e3d9b6c753a0a654cd3 Mon Sep 17 00:00:00 2001 From: jableader Date: Sat, 1 Nov 2025 16:35:39 +1100 Subject: [PATCH] Ported v1 behavioral coverage to v2 counterparts across meals, recipes, shopping, and consumed flows. --- tests/test_meals_consumed_v2.py | 20 +++++ tests/test_meals_write_v2.py | 88 +++++++++++++++++++ tests/test_recipes_household_v2.py | 94 +++++++++++++++++++++ tests/test_shopping_current_structure_v2.py | 57 +++++++++++++ tests/test_shopping_purchase_v2.py | 8 ++ 5 files changed, 267 insertions(+) create mode 100644 tests/test_shopping_current_structure_v2.py diff --git a/tests/test_meals_consumed_v2.py b/tests/test_meals_consumed_v2.py index 5891e79..f3583e4 100644 --- a/tests/test_meals_consumed_v2.py +++ b/tests/test_meals_consumed_v2.py @@ -104,3 +104,23 @@ class TestMealsConsumedV2(unittest.IsolatedAsyncioTestCase): assert r2.status_code == 200 cur2 = r2.json() assert any(i.get("mealId") == self.meal_h2 for i in cur2["requestedMeals"]) # still present + + async def test_mark_consumed_requires_timezone(self): + # Create another meal in H1 + await self.conn.execute( + "INSERT INTO Meal (suggested_date, household_id) VALUES (datetime('now'), ?)", + (self.h1_id,), + ) + async with self.conn.execute("SELECT last_insert_rowid()") as c: + row = await c.fetchone() + assert row is not None + meal_id = int(row[0]) + # Missing tzinfo should 400 + body = {"consumedDate": datetime.datetime.now().replace(tzinfo=None).isoformat()} + r = self.client.post( + f"/api/v1/households/{self.h1}/meals/{meal_id}/consumed", + headers=self.headers, + json=body, + ) + assert r.status_code == 400 + assert "timezone" in r.json()["title"].lower() diff --git a/tests/test_meals_write_v2.py b/tests/test_meals_write_v2.py index b9c783c..75e03ae 100644 --- a/tests/test_meals_write_v2.py +++ b/tests/test_meals_write_v2.py @@ -75,3 +75,91 @@ class TestMealsWriteV2(unittest.IsolatedAsyncioTestCase): f"/api/v1/households/{self.slug}/meals/{meal_id}", headers=self.headers ) assert r3.status_code == 200, r3.text + + def test_create_meal_validation_errors(self): + # Base valid body + base = { + "suggestedDate": datetime.datetime.now().astimezone().isoformat(), + "chefs": [{"id": 1, "name": "A"}], + "cleanup": [{"id": 1, "name": "A"}], + "consumers": [{"id": 1, "name": "A"}], + "recipes": [], + "extraIngredients": [ + {"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""} + ], + } + + # No chefs + body = dict(base) + body["chefs"] = [] + r = self.client.post(f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body) + assert r.status_code == 400 + assert "chef" in r.json()["title"].lower() + + # No cleanup + body = dict(base) + body["cleanup"] = [] + r = self.client.post(f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body) + assert r.status_code == 400 + assert "cleanup" in r.json()["title"].lower() + + # No consumers + body = dict(base) + body["consumers"] = [] + r = self.client.post(f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body) + assert r.status_code == 400 + assert "consumer" in r.json()["title"].lower() + + # No recipes or extra ingredients + body = dict(base) + body["recipes"] = [] + body["extraIngredients"] = [] + r = self.client.post(f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body) + assert r.status_code == 400 + assert "recipe" in r.json()["title"].lower() or "ingredient" in r.json()["title"].lower() + + # Zero servings in recipe + body = dict(base) + body["recipes"] = [{"mealId": -1, "recipeId": 1, "servings": 0}] + r = self.client.post(f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body) + assert r.status_code == 400 + assert "servings" in r.json()["title"].lower() + + def test_update_id_mismatch_and_not_found_and_delete_not_found(self): + # Create a valid meal first + body = { + "suggestedDate": datetime.datetime.now().astimezone().isoformat(), + "chefs": [{"id": 1, "name": "A"}], + "cleanup": [{"id": 1, "name": "A"}], + "consumers": [{"id": 1, "name": "A"}], + "recipes": [], + "extraIngredients": [ + {"name": "Salt", "line": "Salt", "unit": "Items", "quantity": 1, "preparation": ""} + ], + } + r = self.client.post( + f"/api/v1/households/{self.slug}/meals", headers=self.headers, json=body + ) + assert r.status_code == 200, r.text + created = r.json() + + # ID mismatch + mismatch = dict(created) + wrong_id = created["id"] + 123 + r_mis = self.client.put( + f"/api/v1/households/{self.slug}/meals/{wrong_id}", headers=self.headers, json=mismatch + ) + assert r_mis.status_code == 400 + + # Not found update + mismatch["id"] = wrong_id + r_nf = self.client.put( + f"/api/v1/households/{self.slug}/meals/{wrong_id}", headers=self.headers, json=mismatch + ) + assert r_nf.status_code == 404 + + # Delete not found + r_del_nf = self.client.delete( + f"/api/v1/households/{self.slug}/meals/{wrong_id}", headers=self.headers + ) + assert r_del_nf.status_code == 404 diff --git a/tests/test_recipes_household_v2.py b/tests/test_recipes_household_v2.py index 66276e2..01172e7 100644 --- a/tests/test_recipes_household_v2.py +++ b/tests/test_recipes_household_v2.py @@ -116,3 +116,97 @@ class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase): # Subsequent get in same household should be 404 r3 = self.client.get(f"/api/v1/households/{self.h1}/recipes/{rid}", headers=self.headers) assert r3.status_code == 404 + + def test_create_recipe_requires_ingredients_and_cursor_edge(self): + # Create without ingredients -> 400 + bad = { + "id": -1, + "name": "NoIngr", + "link": "https://example.com/no", + "serves": 2, + "imageUrls": [], + "ingredients": [], + } + r = self.client.post( + f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=bad + ) + assert r.status_code == 400, r.text + + # Seed two recipes then exercise cursor behavior + for i in range(2): + good = { + "id": -1, + "name": f"R{i}", + "link": f"https://example.com/r{i}", + "serves": 2, + "imageUrls": [], + "ingredients": [ + { + "id": 0, + "line": "1 A", + "name": "A", + "unit": "Items", + "quantity": 1, + "preparation": "", + "product": None, + } + ], + } + rr = self.client.post( + f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=good + ) + assert rr.status_code == 200, rr.text + + # invalid cursor -> treated as start + r1 = self.client.get( + f"/api/v1/households/{self.h1}/recipes?cursor=notanint&limit=1", + headers=self.headers, + ) + assert r1.status_code == 200 + body1 = r1.json() + assert "items" in body1 + # After last + all_resp = self.client.get( + f"/api/v1/households/{self.h1}/recipes?limit=200", headers=self.headers + ) + items = all_resp.json()["items"] + if items: + last_id = items[-1]["id"] + after = self.client.get( + f"/api/v1/households/{self.h1}/recipes?cursor={last_id}&limit=200", + headers=self.headers, + ) + body_after = after.json() + assert body_after["items"] == [] or body_after.get("nextCursor") is None + + def test_list_recipes_name_filter_q(self): + # Seed a uniquely named recipe + recipe = { + "id": -1, + "name": "UniqueNameZZZ", + "link": "https://example.com/unique", + "serves": 2, + "imageUrls": [], + "ingredients": [ + { + "id": 0, + "line": "1 A", + "name": "A", + "unit": "Items", + "quantity": 1, + "preparation": "", + "product": None, + } + ], + } + r = self.client.post( + f"/api/v1/households/{self.h1}/recipes", headers=self.headers, json=recipe + ) + assert r.status_code == 200, r.text + # Name filter should find it + r2 = self.client.get( + f"/api/v1/households/{self.h1}/recipes?q=UniqueNameZZZ", headers=self.headers + ) + assert r2.status_code == 200 + items = r2.json()["items"] + assert any(it["name"] == "UniqueNameZZZ" for it in items) diff --git a/tests/test_shopping_current_structure_v2.py b/tests/test_shopping_current_structure_v2.py new file mode 100644 index 0000000..f92a3ba --- /dev/null +++ b/tests/test_shopping_current_structure_v2.py @@ -0,0 +1,57 @@ +import unittest +from fastapi.testclient import TestClient + +import main +from db import connect, create +from scripts.migration_to_households import run_migration + + +class TestShoppingCurrentStructureV2(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 user and create a household + r = self.client.post( + "/api/v1/auth/register", + json={"email": "s2@test.com", "password": "pw", "displayName": "S2"}, + ) + 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": "H"}) + assert r.status_code == 200, r.text + self.slug = r.json()["slug"] + + async def asyncTearDown(self): + await self.conn.close() + main.app.dependency_overrides.clear() + + def test_current_payload_keys(self): + r = self.client.get( + f"/api/v1/households/{self.slug}/shopping/current", + headers=self.headers, + ) + assert r.status_code == 200, r.text + body = r.json() + for key in ( + "outstandingItems", + "requestedMeals", + "purchasedItems", + "shoppingListLookup", + "ingredientsLookup", + "recipesLookup", + "mealsLookup", + ): + assert key in body diff --git a/tests/test_shopping_purchase_v2.py b/tests/test_shopping_purchase_v2.py index 1a79f71..9171df0 100644 --- a/tests/test_shopping_purchase_v2.py +++ b/tests/test_shopping_purchase_v2.py @@ -121,3 +121,11 @@ class TestShoppingPurchaseV2(unittest.IsolatedAsyncioTestCase): ) assert resp.status_code == 400, resp.text assert "application/problem+json" in resp.headers.get("content-type", "") + + def test_purchase_unauthorized_without_bearer(self): + # Missing Authorization header should be 401 via get_current_user + resp = self.client.post( + f"/api/v1/households/{self.h1}/shopping", + json={"storeName": "woolworths", "items": []}, + ) + assert resp.status_code == 401