import unittest import asyncio from datetime import datetime from unittest.mock import AsyncMock, Mock, patch import tests.test_data as test_data import importlib def reload_test_data(): global test_data test_data = importlib.reload(test_data) from db import connect, create import shopping import shopping.db as shopping_db from shopping.db import ShoppingList, ShoppingListItem, StoreEnum import ingredients.db as ingredients_db import products.db as products_db import persons import meals from meals.db import MealRecipe import recipes class TestShoppingModels(unittest.IsolatedAsyncioTestCase): """Test the shopping data models""" async def asyncSetUp(self): self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() return await super().asyncSetUp() async def asyncTearDown(self) -> None: await self.conn.close() return await super().asyncTearDown() def test_shopping_list_item_creation(self): """Test basic ShoppingListItem creation""" ingredient = ingredients_db.Ingredient( id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) item = ShoppingListItem( id=1, ingredient_id=ingredient.id, person_id=1, created_date=datetime.now() ) self.assertEqual(item.id, 1) self.assertEqual(item.ingredient_id, 1) self.assertEqual(item.person_id, 1) def test_shopping_list_creation(self): """Test basic ShoppingList creation""" shopping_list = ShoppingList(id=1, store_name=StoreEnum.woolworths, purchased_by_id=1) self.assertEqual(shopping_list.id, 1) self.assertEqual(shopping_list.store_name, StoreEnum.woolworths) self.assertEqual(shopping_list.purchased_by_id, 1) self.assertEqual(shopping_list.items, []) def test_store_enum_values(self): """Test StoreEnum values""" self.assertEqual(StoreEnum.woolworths, "woolworths") self.assertEqual(StoreEnum.coles, "coles") self.assertEqual(StoreEnum.home, "") class TestShoppingValidation(unittest.IsolatedAsyncioTestCase): """Test shopping validation functions""" async def asyncSetUp(self): self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() return await super().asyncSetUp() async def asyncTearDown(self) -> None: await self.conn.close() return await super().asyncTearDown() def test_validate_request_valid_ingredient(self): """Test validation of valid ingredient request""" ingredient = ingredients_db.Ingredient( id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) item = ShoppingListItem(ingredient_id=ingredient.id, person_id=1) # Should not raise any exception shopping_db.validate_request(item) def test_validate_request_valid_meal(self): """Test validation of valid meal request""" meal = meals.Meal(id=1, name="Dinner", suggested_date=datetime.now()) item = ShoppingListItem(meal_id=meal.id, person_id=1) # Should not raise any exception shopping_db.validate_request(item) def test_validate_request_no_person(self): """Test validation fails when no person is specified""" ingredient = ingredients_db.Ingredient( id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) item = ShoppingListItem(ingredient_id=ingredient.id, person_id=-1) # Invalid person id with self.assertRaises(ValueError) as context: shopping_db.validate_request(item) self.assertIn("Requests must have a person", str(context.exception)) def test_validate_request_no_ingredient_or_meal(self): """Test validation fails when neither ingredient nor meal is specified""" item = ShoppingListItem(person_id=1) with self.assertRaises(ValueError) as context: shopping_db.validate_request(item) self.assertIn("Request must have either an ingredient or a meal", str(context.exception)) class TestShoppingRequests(unittest.IsolatedAsyncioTestCase): """Test shopping request functionality""" async def asyncSetUp(self): self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() return await super().asyncSetUp() async def asyncTearDown(self) -> None: await self.conn.close() return await super().asyncTearDown() async def test_request_ingredient(self): """Test requesting an ingredient""" # Create test ingredient ingredient = ingredients_db.Ingredient( name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) # Request the ingredient person = test_data.Persons.jacob item = await shopping_db.request(self.conn, person, ingredient=ingredient) self.assertIsNotNone(item.id) self.assertEqual(item.ingredient_id, ingredient.id) self.assertEqual(item.person_id, person.id) self.assertIsNone(item.meal_id) async def test_request_meal(self): """Test requesting a meal""" # Create a mock meal with proper structure meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) with patch("shopping.db.is_requested", return_value=False): person = test_data.Persons.jacob item = await shopping_db.request(self.conn, person, meal=meal) self.assertIsNotNone(item.id) self.assertEqual(item.meal_id, meal.id) self.assertEqual(item.person_id, person.id) self.assertIsNone(item.ingredient_id) async def test_request_both_ingredient_and_meal_fails(self): """Test that requesting both ingredient and meal fails""" ingredient = ingredients_db.Ingredient( id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) person = test_data.Persons.jacob with self.assertRaises(ValueError) as context: await shopping_db.request(self.conn, person, ingredient=ingredient, meal=meal) self.assertIn("Cannot request both an ingredient and a meal", str(context.exception)) async def test_request_neither_ingredient_nor_meal_fails(self): """Test that requesting neither ingredient nor meal fails""" person = test_data.Persons.jacob with self.assertRaises(ValueError) as context: await shopping_db.request(self.conn, person) self.assertIn( "Must specify either an ingredient or a meal to request", str(context.exception) ) async def test_request_meal_already_requested_fails(self): """Test that requesting an already requested meal fails""" meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) person = test_data.Persons.jacob with patch("shopping.db.is_requested", return_value=True): with self.assertRaises(ValueError) as context: await shopping_db.request(self.conn, person, meal=meal) self.assertIn("Meal is already requested", str(context.exception)) async def test_remove_request_meal(self): """Test removing a meal request""" # First create a meal request meal = meals.Meal(id=1, name="Test Meal", suggested_date=datetime.now()) person = test_data.Persons.jacob with patch("shopping.db.is_requested", return_value=False): await shopping_db.request(self.conn, person, meal=meal) # Then remove it result = await shopping_db.remove_request(self.conn, meal=meal) self.assertTrue(result) async def test_remove_request_ingredient(self): """Test removing an ingredient request""" # Create test ingredient ingredient = ingredients_db.Ingredient( name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) # Remove the request result = await shopping_db.remove_request(self.conn, person=person, ingredient=ingredient) self.assertTrue(result) async def test_remove_request_invalid_parameters_fails(self): """Test that removing request with invalid parameters fails""" person = test_data.Persons.jacob with self.assertRaises(ValueError) as context: await shopping_db.remove_request(self.conn, person=person) self.assertIn( "Must specify either a meal or an ingredient to remove", str(context.exception) ) class TestShoppingPurchase(unittest.IsolatedAsyncioTestCase): """Test shopping purchase functionality""" async def asyncSetUp(self): self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() return await super().asyncSetUp() async def asyncTearDown(self) -> None: await self.conn.close() return await super().asyncTearDown() async def test_purchase_shopping_list(self): """Test purchasing a shopping list""" # Create test ingredient ingredient = ingredients_db.Ingredient( name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) # First create a request for the ingredient person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) # Create shopping list item (this will reference the existing request) item = ShoppingListItem(ingredient=ingredient, person_id=test_data.Persons.jacob.id) # Create shopping list shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=test_data.Persons.jacob.id, items=[item], ) await shopping_db.purchase(self.conn, shopping_list) self.assertIsNotNone(shopping_list.id) self.assertIsNotNone(item.list_id) self.assertEqual(item.list_id, shopping_list.id) async def test_purchase_no_person_fails(self): """Test that purchasing without a person fails""" shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=-1, items=[] # Invalid person id ) with self.assertRaises(ValueError) as context: await shopping_db.purchase(self.conn, shopping_list) self.assertIn("Shopping list must have a person id", str(context.exception)) async def test_purchase_no_items_fails(self): """Test that purchasing with no items fails""" shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=test_data.Persons.jacob.id, items=[] ) with self.assertRaises(ValueError) as context: await shopping_db.purchase(self.conn, shopping_list) self.assertIn("Shopping list must have items", str(context.exception)) class TestShoppingHelperFunctions(unittest.IsolatedAsyncioTestCase): """Test shopping helper functions""" async def asyncSetUp(self): self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() return await super().asyncSetUp() async def asyncTearDown(self) -> None: await self.conn.close() return await super().asyncTearDown() async def test_to_lookups(self): """Test to_lookups function""" # Create actual items with proper IDs - need to insert them first to get valid lookups ingredient = ingredients_db.Ingredient( id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) # Insert the ingredient to get a valid ID await ingredients_db.insert_ingredient(self.conn, ingredient) # Insert test meal and recipe to get valid IDs meal = meals.Meal(id=1, suggested_date=datetime.now()) await meals.insert_meal(self.conn, meal) recipe = recipes.Recipe( id=1, name="Test Recipe", link="http://example.com", serves=4, created_by_id=1 ) await recipes.insert_recipe(self.conn, recipe) items = [ ShoppingListItem(ingredient_id=ingredient.id, meal_id=meal.id, recipe_id=recipe.id) ] # Test the function - it should populate the lookups based on IDs meals_lookup, recipes_lookup, ingredients_lookup = await shopping.to_lookups( self.conn, items ) # Check that the lookups contain our objects self.assertEqual(len(meals_lookup), 1) self.assertEqual(len(recipes_lookup), 1) self.assertEqual(len(ingredients_lookup), 1) self.assertEqual(meals_lookup[1].id, meal.id) self.assertEqual(recipes_lookup[1].id, recipe.id) self.assertEqual(ingredients_lookup[1].id, ingredient.id) # Items should still have their IDs self.assertEqual(items[0].meal_id, meal.id) self.assertEqual(items[0].recipe_id, recipe.id) self.assertEqual(items[0].ingredient_id, ingredient.id) def test_flatten_items_with_meal(self): """Test flatten_items function with meal items""" # Create meal with recipes and ingredients recipe_ingredient = ingredients_db.Ingredient( id=1, name="Recipe Ingredient", line="500g recipe ingredient", unit="g", quantity=500.0, preparation="chopped", ) extra_ingredient = ingredients_db.Ingredient( id=2, name="Extra Ingredient", line="200g extra ingredient", unit="g", quantity=200.0, preparation="diced", ) recipe = recipes.Recipe( id=1, name="Test Recipe", link="http://example.com", serves=4, created_by_id=1, ingredients=[recipe_ingredient], ) meal_recipe = MealRecipe(meal_id=1, recipe_id=1, servings=2.0, recipe=recipe) meal = meals.Meal( id=1, suggested_date=datetime.now(), recipes=[meal_recipe], extra_ingredients=[extra_ingredient], ) item = ShoppingListItem(meal_id=meal.id, person_id=1) # Create lookups for flatten_items meals_lookup = {meal.id: meal} flattened = list(shopping.flatten_items([item], meals_lookup)) # Should have 2 items: one for recipe ingredient, one for extra ingredient self.assertEqual(len(flattened), 2) self.assertEqual(flattened[0].ingredient_id, 1) self.assertEqual(flattened[1].ingredient_id, 2) def test_flatten_items_without_meal(self): """Test flatten_items function with non-meal items""" ingredient = ingredients_db.Ingredient( id=1, name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) item = ShoppingListItem(ingredient_id=ingredient.id, person_id=1) # Empty lookups since no meal/recipe is involved flattened = list(shopping.flatten_items([item], {})) self.assertEqual(len(flattened), 1) self.assertEqual(flattened[0], item) async def test_get_persons_requests(self): """Test get_persons_requests function""" person_id = 1 # Create actual ingredients and requests ingredient = ingredients_db.Ingredient( name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) # Create a request for person 1 person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) # Get the person's requests requests = await shopping.get_persons_requests(self.conn, person_id) # Should have one request for the ingredient self.assertEqual(len(requests), 1) self.assertEqual(requests[0].id, ingredient.id) async def test_get_outstanding_requests(self): """Test get_outstanding_requests function""" # Create actual data ingredient = ingredients_db.Ingredient( name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) # Create requests person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) # Test the function outstanding, purchased, meal_requests, _, _, _ = await shopping.get_outstanding_requests( self.conn ) # Should have one outstanding ingredient request self.assertGreaterEqual(len(outstanding), 1) self.assertEqual(len(purchased), 0) self.assertEqual(len(meal_requests), 0) async def test_is_requested(self): """Test is_requested function""" meal = meals.Meal(id=1, suggested_date=datetime.now()) # Test with a meal that hasn't been requested result = await shopping_db.is_requested(self.conn, meal) self.assertFalse(result) # Create a request for the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) # Now it should be requested result = await shopping_db.is_requested(self.conn, meal) self.assertTrue(result) async def test_is_requested_invalid_meal(self): """Test is_requested with invalid meal""" meal = meals.Meal(id=-1, name="Invalid Meal", suggested_date=datetime.now()) result = await shopping_db.is_requested(self.conn, meal) self.assertFalse(result) async def test_load_shopping_list(self): """Test load_shopping_list function""" # Create and purchase a shopping list first ingredient = ingredients_db.Ingredient( name="Broccoli", line="500g fresh broccoli", unit="g", quantity=500.0, preparation="chopped", ) await ingredients_db.insert_ingredient(self.conn, ingredient) # First create a request for the ingredient person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient) item = ShoppingListItem(ingredient_id=ingredient.id, person_id=test_data.Persons.jacob.id) shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=test_data.Persons.jacob.id, items=[item], ) await shopping_db.purchase(self.conn, shopping_list) # Now load it back loaded_list = await shopping_db.load_shopping_list(self.conn, shopping_list.id) self.assertIsNotNone(loaded_list) self.assertEqual(loaded_list.id, shopping_list.id) self.assertEqual(loaded_list.store_name, shopping_list.store_name) self.assertEqual(len(loaded_list.items), 1) class TestShoppingComplexEdgeCases(unittest.IsolatedAsyncioTestCase): """Test shopping complex edge cases for meal requests and purchases""" async def asyncSetUp(self): self.conn = await connect(":memory:") await create(self.conn) await test_data.create_persons(self.conn) reload_test_data() return await super().asyncSetUp() async def asyncTearDown(self) -> None: await self.conn.close() return await super().asyncTearDown() async def test_meal_request_includes_ingredients_in_outstanding(self): """Test that when user requests a meal, get_outstanding_requests includes the ingredients of that meal""" # Create a recipe with ingredients recipe = recipes.Recipe( id=-1, name="Test Pasta Recipe", link="http://example.com/pasta", serves=4, created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) # Create ingredients for the recipe pasta_ingredient = ingredients_db.Ingredient( name="Pasta", line="500g pasta", unit="g", quantity=500.0, preparation="", recipe_id=recipe.id, ) tomato_ingredient = ingredients_db.Ingredient( name="Tomatoes", line="400g canned tomatoes", unit="g", quantity=400.0, preparation="", recipe_id=recipe.id, ) await ingredients_db.insert_ingredient(self.conn, pasta_ingredient) await ingredients_db.insert_ingredient(self.conn, tomato_ingredient) # Load the recipe with its ingredients await recipes.load_recipe_ingredients(self.conn, recipe) # Create a meal with this recipe and extra ingredients extra_ingredient = ingredients_db.Ingredient( name="Garlic Bread", line="1 loaf garlic bread", unit="loaf", quantity=1.0, preparation="", ) meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=2.0, recipe=recipe) meal = meals.Meal( id=-1, suggested_date=datetime.now(), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], recipes=[meal_recipe], extra_ingredients=[extra_ingredient], ) # Insert the meal await meals.insert_meal(self.conn, meal) # Request the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) # Get outstanding requests ( outstanding, purchased, meal_requests, meals_lookup, recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(self.conn) # Should have one meal request self.assertEqual(len(meal_requests), 1) self.assertEqual(meal_requests[0].meal_id, meal.id) # Should have 3 outstanding items: 2 from recipe + 1 extra ingredient self.assertEqual(len(outstanding), 3) # Check that all ingredients are included ingredient_names = { ingredients_lookup[item.ingredient_id].name for item in outstanding if item.ingredient_id in ingredients_lookup } self.assertIn("Pasta", ingredient_names) self.assertIn("Tomatoes", ingredient_names) self.assertIn("Garlic Bread", ingredient_names) # All should be associated with the meal for item in outstanding: self.assertEqual(item.meal_id, meal.id) async def test_partial_meal_purchase_moves_item_to_purchased(self): """Test that a user can purchase an individual item from a meal, moving it to purchased items""" # Create a recipe with multiple ingredients recipe = recipes.Recipe( id=-1, name="Multi-Ingredient Recipe", link="http://example.com/multi", serves=4, created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) # Create multiple ingredients for the recipe ingredient1 = ingredients_db.Ingredient( name="Rice", line="200g rice", unit="g", quantity=200.0, preparation="", recipe_id=recipe.id, ) ingredient2 = ingredients_db.Ingredient( name="Chicken", line="300g chicken breast", unit="g", quantity=300.0, preparation="", recipe_id=recipe.id, ) ingredient3 = ingredients_db.Ingredient( name="Vegetables", line="150g mixed vegetables", unit="g", quantity=150.0, preparation="", recipe_id=recipe.id, ) await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) await ingredients_db.insert_ingredient(self.conn, ingredient3) # Load the recipe with its ingredients await recipes.load_recipe_ingredients(self.conn, recipe) # Create and insert a meal meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=2.0, recipe=recipe) meal = meals.Meal( id=-1, suggested_date=datetime.now(), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], recipes=[meal_recipe], ) await meals.insert_meal(self.conn, meal) # Request the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) # Get initial outstanding requests ( outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 3) # All 3 ingredients self.assertEqual(len(purchased_before), 0) # Nothing purchased yet # Purchase only one ingredient (Rice) from the meal rice_item = None for item in outstanding_before: if ( item.ingredient_id in ingredients_lookup and ingredients_lookup[item.ingredient_id].name == "Rice" ): rice_item = ShoppingListItem( ingredient_id=item.ingredient_id, person_id=person.id, meal_id=meal.id, recipe_id=recipe.id, ) break self.assertIsNotNone(rice_item) # Create and purchase a shopping list with just the rice shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[rice_item] ) await shopping_db.purchase(self.conn, shopping_list) # Get outstanding requests after purchase ( outstanding_after, purchased_after, meal_requests_after, meals_lookup2, recipes_lookup2, ingredients_lookup2, ) = await shopping.get_outstanding_requests(self.conn) # Should have 2 outstanding items (Chicken and Vegetables) self.assertEqual(len(outstanding_after), 2) outstanding_names = { ingredients_lookup2[item.ingredient_id].name for item in outstanding_after if item.ingredient_id in ingredients_lookup2 } self.assertIn("Chicken", outstanding_names) self.assertIn("Vegetables", outstanding_names) self.assertNotIn("Rice", outstanding_names) # Should have 1 purchased item (Rice) self.assertEqual(len(purchased_after), 1) self.assertEqual(purchased_after[0].ingredient_id, ingredient1.id) # Meal should still be requested (not all ingredients purchased) self.assertEqual(len(meal_requests_after), 1) async def test_complete_meal_purchase_unrequests_and_marks_purchased(self): """Test that when all items of a meal are purchased, the meal is unrequested and marked as purchased""" # Create a simple recipe with 2 ingredients recipe = recipes.Recipe( id=-1, name="Simple Recipe", link="http://example.com/simple", serves=2, created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) # Create ingredients for the recipe ingredient1 = ingredients_db.Ingredient( name="Bread", line="2 slices bread", unit="slices", quantity=2.0, preparation="", recipe_id=recipe.id, ) ingredient2 = ingredients_db.Ingredient( name="Butter", line="10g butter", unit="g", quantity=10.0, preparation="", recipe_id=recipe.id, ) await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) # Load the recipe with its ingredients await recipes.load_recipe_ingredients(self.conn, recipe) # Create and insert a meal meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) meal = meals.Meal( id=-1, suggested_date=datetime.now(), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], recipes=[meal_recipe], ) await meals.insert_meal(self.conn, meal) # Request the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) # Verify meal is requested is_requested_before = await shopping_db.is_requested(self.conn, meal) self.assertTrue(is_requested_before) # Get initial outstanding requests ( outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) # Both ingredients self.assertEqual(len(meal_requests_before), 1) # Meal is requested # Verify the meal is not marked as purchased yet found_meal_before = await meals.find_meal_by_id(self.conn, meal.id) self.assertIsNone(found_meal_before.purchase_date) # Purchase all ingredients from the meal shopping_items = [] for item in outstanding_before: shopping_items.append( ShoppingListItem( ingredient_id=item.ingredient_id, person_id=person.id, meal_id=meal.id, recipe_id=recipe.id, ) ) shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=shopping_items ) await shopping_db.purchase(self.conn, shopping_list) # Verify meal is no longer requested is_requested_after = await shopping_db.is_requested(self.conn, meal) self.assertFalse(is_requested_after) # Verify meal is marked as purchased found_meal_after = await meals.find_meal_by_id(self.conn, meal.id) self.assertIsNotNone(found_meal_after.purchase_date) # Get outstanding requests after complete purchase ( outstanding_after, purchased_after, meal_requests_after, _, _, _, ) = await shopping.get_outstanding_requests(self.conn) # Should have no outstanding items from this meal self.assertEqual(len(outstanding_after), 0) # Should have no purchased items (meal is complete so ingredients don't appear) self.assertEqual(len(purchased_after), 0) # Should have no meal requests self.assertEqual(len(meal_requests_after), 0) async def test_complete_meal_with_extra_ingredients_purchase(self): """Test that meals with both recipe ingredients and extra ingredients are properly handled""" # Create a recipe with 1 ingredient recipe = recipes.Recipe( id=-1, name="Recipe with Extra", link="http://example.com/extra", serves=2, created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) # Create recipe ingredient recipe_ingredient = ingredients_db.Ingredient( name="Main Ingredient", line="200g main ingredient", unit="g", quantity=200.0, preparation="", recipe_id=recipe.id, ) await ingredients_db.insert_ingredient(self.conn, recipe_ingredient) await recipes.load_recipe_ingredients(self.conn, recipe) # Create extra ingredient (not part of recipe) extra_ingredient = ingredients_db.Ingredient( name="Side Dish", line="1 side dish", unit="item", quantity=1.0, preparation="" ) # Create and insert a meal with both recipe and extra ingredients meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) meal = meals.Meal( id=-1, suggested_date=datetime.now(), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], recipes=[meal_recipe], extra_ingredients=[extra_ingredient], ) await meals.insert_meal(self.conn, meal) # Request the meal person = test_data.Persons.jacob await shopping_db.request(self.conn, person, meal=meal) # Get initial outstanding requests ( outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) # Recipe ingredient + extra ingredient self.assertEqual(len(meal_requests_before), 1) # Purchase all ingredients shopping_items = [] for item in outstanding_before: shopping_items.append( ShoppingListItem( ingredient_id=item.ingredient_id, person_id=person.id, meal_id=meal.id, recipe_id=item.recipe_id, ) ) shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=shopping_items ) await shopping_db.purchase(self.conn, shopping_list) # Verify meal is unrequested and marked as purchased is_requested_after = await shopping_db.is_requested(self.conn, meal) self.assertFalse(is_requested_after) found_meal_after = await meals.find_meal_by_id(self.conn, meal.id) self.assertIsNotNone(found_meal_after.purchase_date) # Get final state ( outstanding_after, purchased_after, meal_requests_after, _, _, _, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_after), 0) self.assertEqual(len(purchased_after), 0) # No purchased items since meal is complete self.assertEqual(len(meal_requests_after), 0) async def test_individual_ingredient_purchase_without_meal(self): """Test purchasing individual ingredients that are not part of a meal""" # Create individual ingredients ingredient1 = ingredients_db.Ingredient( name="Milk", line="1L milk", unit="L", quantity=1.0, preparation="" ) ingredient2 = ingredients_db.Ingredient( name="Eggs", line="12 eggs", unit="dozen", quantity=1.0, preparation="" ) await ingredients_db.insert_ingredient(self.conn, ingredient1) await ingredients_db.insert_ingredient(self.conn, ingredient2) # Request individual ingredients (not part of any meal) person = test_data.Persons.jacob await shopping_db.request(self.conn, person, ingredient=ingredient1) await shopping_db.request(self.conn, person, ingredient=ingredient2) # Verify both ingredients appear in outstanding requests ( outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) self.assertEqual(len(purchased_before), 0) self.assertEqual(len(meal_requests_before), 0) # No meal requests # Verify the ingredients in outstanding requests ingredient_names = { ingredients_lookup[item.ingredient_id].name for item in outstanding_before if item.ingredient_id in ingredients_lookup } self.assertIn("Milk", ingredient_names) self.assertIn("Eggs", ingredient_names) # All should be individual requests (no meal_id) for item in outstanding_before: self.assertIsNone(item.meal_id) self.assertEqual(item.person_id, person.id) # Purchase only one ingredient (Milk) milk_item = None for item in outstanding_before: if ( item.ingredient_id in ingredients_lookup and ingredients_lookup[item.ingredient_id].name == "Milk" ): milk_item = ShoppingListItem(ingredient_id=item.ingredient_id, person_id=person.id) break self.assertIsNotNone(milk_item) shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[milk_item] ) await shopping_db.purchase(self.conn, shopping_list) # Verify only eggs remains in outstanding, milk is purchased ( outstanding_after, purchased_after, meal_requests_after, meals_lookup_after, recipes_lookup_after, ingredients_lookup_after, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_after), 1) self.assertEqual( len(purchased_after), 0 ) # Individual purchases don't appear in purchased list self.assertEqual(len(meal_requests_after), 0) # Verify only eggs remains self.assertEqual(ingredients_lookup_after[outstanding_after[0].ingredient_id].name, "Eggs") self.assertIsNone(outstanding_after[0].meal_id) # Purchase the remaining ingredient (Eggs) eggs_item = ShoppingListItem( ingredient_id=outstanding_after[0].ingredient_id, person_id=person.id ) shopping_list2 = ShoppingList( store_name=StoreEnum.coles, purchased_by_id=person.id, items=[eggs_item] ) await shopping_db.purchase(self.conn, shopping_list2) # Verify no outstanding requests remain ( outstanding_final, purchased_final, meal_requests_final, _, _, _, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_final), 0) self.assertEqual(len(purchased_final), 0) self.assertEqual(len(meal_requests_final), 0) async def test_mixed_meal_and_individual_requests(self): """Test a mix of meal requests and individual ingredient requests""" # Create a simple recipe and meal recipe = recipes.Recipe( id=-1, name="Simple Pasta", link="http://example.com/pasta", serves=2, created_by_id=test_data.Persons.jacob.id, ) await recipes.insert_recipe(self.conn, recipe) # Create recipe ingredient pasta_ingredient = ingredients_db.Ingredient( name="Pasta", line="200g pasta", unit="g", quantity=200.0, preparation="", recipe_id=recipe.id, ) await ingredients_db.insert_ingredient(self.conn, pasta_ingredient) await recipes.load_recipe_ingredients(self.conn, recipe) # Create meal meal_recipe = MealRecipe(meal_id=-1, recipe_id=recipe.id, servings=1.0, recipe=recipe) meal = meals.Meal( id=-1, suggested_date=datetime.now(), chefs=[test_data.Persons.jacob], cleanup=[test_data.Persons.ryan], consumers=[test_data.Persons.ellie], recipes=[meal_recipe], ) await meals.insert_meal(self.conn, meal) # Create individual ingredient snack_ingredient = ingredients_db.Ingredient( name="Chips", line="1 bag chips", unit="bag", quantity=1.0, preparation="" ) await ingredients_db.insert_ingredient(self.conn, snack_ingredient) person = test_data.Persons.jacob # Request both meal and individual ingredient await shopping_db.request(self.conn, person, meal=meal) await shopping_db.request(self.conn, person, ingredient=snack_ingredient) # Verify we have both meal and individual requests ( outstanding_before, purchased_before, meal_requests_before, meals_lookup, recipes_lookup, ingredients_lookup, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_before), 2) # Pasta from meal + Chips individual self.assertEqual(len(purchased_before), 0) self.assertEqual(len(meal_requests_before), 1) # One meal request # Verify the mix of ingredients ingredient_names = { ingredients_lookup[item.ingredient_id].name for item in outstanding_before if item.ingredient_id in ingredients_lookup } self.assertIn("Pasta", ingredient_names) self.assertIn("Chips", ingredient_names) # Check that pasta is from meal, chips is individual pasta_item = None chips_item = None for item in outstanding_before: if item.ingredient_id in ingredients_lookup: ingredient_name = ingredients_lookup[item.ingredient_id].name if ingredient_name == "Pasta": pasta_item = item elif ingredient_name == "Chips": chips_item = item self.assertIsNotNone(pasta_item) self.assertIsNotNone(chips_item) self.assertEqual(pasta_item.meal_id, meal.id) self.assertIsNone(chips_item.meal_id) # Purchase the individual ingredient (Chips) chips_shopping_item = ShoppingListItem( ingredient_id=chips_item.ingredient_id, person_id=person.id ) shopping_list = ShoppingList( store_name=StoreEnum.woolworths, purchased_by_id=person.id, items=[chips_shopping_item] ) await shopping_db.purchase(self.conn, shopping_list) # Verify only meal ingredient remains ( outstanding_after, purchased_after, meal_requests_after, meals_lookup_after, recipes_lookup_after, ingredients_lookup_after, ) = await shopping.get_outstanding_requests(self.conn) self.assertEqual(len(outstanding_after), 1) # Only pasta from meal self.assertEqual(len(purchased_after), 0) self.assertEqual(len(meal_requests_after), 1) # Meal still requested self.assertEqual(ingredients_lookup_after[outstanding_after[0].ingredient_id].name, "Pasta") self.assertEqual(outstanding_after[0].meal_id, meal.id) if __name__ == "__main__": unittest.main()