diff --git a/frontend/composables/recipes/use-recipe-ingredients.ts b/frontend/composables/recipes/use-recipe-ingredients.ts index a03562b17..e6557654e 100644 --- a/frontend/composables/recipes/use-recipe-ingredients.ts +++ b/frontend/composables/recipes/use-recipe-ingredients.ts @@ -82,7 +82,6 @@ export function useParsedIngredientText(ingredient: RecipeIngredient, scale = 1, } } - // TODO: Add support for sub-recipes here? const unitName = useUnitName(unit || undefined, usePluralUnit); const ingName = referencedRecipe ? referencedRecipe.name || "" : useFoodName(food || undefined, usePluralFood); diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index 21b91357e..340738adc 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -369,25 +369,35 @@ class RecipeService(RecipeServiceBase): return new_recipe - def has_recursive_recipe_link(self, recipe: Recipe, visited: set[str] | None = None): + def has_recursive_recipe_link(self, recipe: Recipe, path: set[str] | None = None): """Recursively checks if a recipe links to itself through its ingredients.""" + if path is None: + path = set() - if visited is None: - visited = set() recipe_id = str(getattr(recipe, "id", None)) - if recipe_id in visited: + + # Check if this recipe is already in the current path (cycle detected) + if recipe_id in path: return True - visited.add(recipe_id) - ingredients = getattr(recipe, "recipe_ingredient", []) - for ing in ingredients: - try: - sub_recipe = self.get_one(ing.referenced_recipe.id) - except (AttributeError, exceptions.NoEntryFound): - continue + # Add to the current path + path.add(recipe_id) + + try: + ingredients = getattr(recipe, "recipe_ingredient", []) + for ing in ingredients: + try: + sub_recipe = self.get_one(ing.referenced_recipe.id) + except (AttributeError, exceptions.NoEntryFound): + continue + + # Recursively check - path is modified in place and cleaned up via backtracking + if self.has_recursive_recipe_link(sub_recipe, path): + return True + finally: + # Backtrack: remove this recipe from the path when done exploring this branch + path.discard(recipe_id) - if self.has_recursive_recipe_link(sub_recipe, visited): - return True return False def _pre_update_check(self, slug_or_id: str | UUID, new_data: Recipe) -> Recipe: diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py index 99d41f91f..15b643b43 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -691,6 +691,130 @@ def test_recipe_recursion_cycle_three_level(api_client: TestClient, unique_user: assert "cannot reference itself" in response.text.lower() +def test_recipe_recursion_valid_branched_chain(api_client: TestClient, unique_user: TestUser): + """Test that valid branched nesting without cycles is allowed (d -> b -> a, d -> c -> a).""" + database = unique_user.repos + + food = database.ingredient_foods.create( + SaveIngredientFood( + name=random_string(10), + group_id=unique_user.group_id, + ) + ) + + # Create recipe_a + recipe_a: Recipe = database.recipes.create( + Recipe( + name=random_string(10), + user_id=unique_user.user_id, + group_id=unique_user.group_id, + recipe_ingredient=[ + RecipeIngredient(note="", food=food), + ], + ) + ) + + # Create recipe_b + recipe_b: Recipe = database.recipes.create( + Recipe( + name=random_string(10), + user_id=unique_user.user_id, + group_id=unique_user.group_id, + recipe_ingredient=[ + RecipeIngredient(note="", referenced_recipe=recipe_a), + ], + ) + ) + + # Create recipe_c + recipe_c: Recipe = database.recipes.create( + Recipe( + name=random_string(10), + user_id=unique_user.user_id, + group_id=unique_user.group_id, + recipe_ingredient=[ + RecipeIngredient(note="", referenced_recipe=recipe_a), + ], + ) + ) + + # Create recipe_d to reference recipe_b and recipe_c + recipe_d: Recipe = database.recipes.create( + Recipe( + name=random_string(10), + user_id=unique_user.user_id, + group_id=unique_user.group_id, + recipe_ingredient=[ + RecipeIngredient(note="", referenced_recipe=recipe_b), + RecipeIngredient(note="", referenced_recipe=recipe_c), + ], + ) + ) + recipe_url = api_routes.recipes_slug(recipe_d.slug) + response = api_client.get(recipe_url, headers=unique_user.token) + assert response.status_code == 200 + recipe_data = json.loads(response.text) + + response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token) + assert response.status_code == 200 + + +def test_recipe_recursion_same_recipe_twice(api_client: TestClient, unique_user: TestUser): + """Test that referencing the same recipe multiple times in one recipe is allowed. + + This tests the bug where using the same sub-recipe twice (e.g., a spice mix used + in both marinade and vegetables) incorrectly triggered a cycle detection. + """ + database = unique_user.repos + + food = database.ingredient_foods.create( + SaveIngredientFood( + name=random_string(10), + group_id=unique_user.group_id, + ) + ) + + # Create a spice mix recipe + sub_recipe = database.recipes.create( + Recipe( + name=random_string(10), + user_id=unique_user.user_id, + group_id=unique_user.group_id, + recipe_ingredient=[ + RecipeIngredient(note="", food=food), + ], + ) + ) + + # Create a main recipe that uses the spice mix twice + main_recipe = database.recipes.create( + Recipe( + name=random_string(10), + user_id=unique_user.user_id, + group_id=unique_user.group_id, + recipe_ingredient=[ + RecipeIngredient(note="For marinade", referenced_recipe=sub_recipe), + RecipeIngredient(note="For vegetables", referenced_recipe=sub_recipe), + ], + ) + ) + + # Verify we can fetch and update the recipe without errors + recipe_url = api_routes.recipes_slug(main_recipe.slug) + response = api_client.get(recipe_url, headers=unique_user.token) + assert response.status_code == 200 + recipe_data = json.loads(response.text) + + # Verify both ingredients are present + assert len(recipe_data["recipeIngredient"]) == 2 + assert recipe_data["recipeIngredient"][0]["note"] == "For marinade" + assert recipe_data["recipeIngredient"][1]["note"] == "For vegetables" + + # Try to update the recipe - this should not fail with a recursion error + response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token) + assert response.status_code == 200 + + def test_recipe_reference_deleted(api_client: TestClient, unique_user: TestUser): """Test that when a referenced recipe is deleted, the parent recipe remains intact.""" database = unique_user.repos