mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-23 18:55:15 -05:00
feat: Add recipe as ingredient (#4800)
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
ff42964537
commit
60d9294861
@@ -11,6 +11,7 @@ from mealie.schema.household.group_shopping_list import (
|
||||
ShoppingListOut,
|
||||
)
|
||||
from mealie.schema.recipe.recipe import Recipe
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
|
||||
from tests import utils
|
||||
from tests.utils import api_routes
|
||||
from tests.utils.assertion_helpers import assert_deserialize
|
||||
@@ -245,6 +246,121 @@ def test_shopping_lists_add_recipes(
|
||||
assert refs_by_id[str(recipe.id)]["recipeQuantity"] == 2
|
||||
|
||||
|
||||
def test_shopping_lists_add_nested_recipe_ingredients(
|
||||
api_client: TestClient,
|
||||
unique_user: TestUser,
|
||||
shopping_lists: list[ShoppingListOut],
|
||||
):
|
||||
"""Test that adding a recipe with nested recipe ingredients flattens all ingredients (a -> b -> c)."""
|
||||
shopping_list = random.choice(shopping_lists)
|
||||
database = unique_user.repos
|
||||
|
||||
# Create three food items for the base recipes
|
||||
food_c = database.ingredient_foods.create(
|
||||
SaveIngredientFood(
|
||||
name=random_string(10),
|
||||
group_id=unique_user.group_id,
|
||||
)
|
||||
)
|
||||
|
||||
food_b = database.ingredient_foods.create(
|
||||
SaveIngredientFood(
|
||||
name=random_string(10),
|
||||
group_id=unique_user.group_id,
|
||||
)
|
||||
)
|
||||
|
||||
food_a = database.ingredient_foods.create(
|
||||
SaveIngredientFood(
|
||||
name=random_string(10),
|
||||
group_id=unique_user.group_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Create recipe_c with a single food ingredient (base recipe)
|
||||
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=f"ingredient from recipe c - {food_c.name}", food=food_c, quantity=1),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Create recipe_b with its own food ingredient and a reference to recipe_c (c -> 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=f"ingredient from recipe b - {food_b.name}", food=food_b, quantity=2),
|
||||
RecipeIngredient(note="nested recipe c", referenced_recipe=recipe_c),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Create recipe_a with its own food ingredient and a reference to recipe_b (b -> a, creating chain c -> b -> 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=f"ingredient from recipe a - {food_a.name}", food=food_a, quantity=3),
|
||||
RecipeIngredient(note="nested recipe b", referenced_recipe=recipe_b),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Add recipe_a to the shopping list
|
||||
response = api_client.post(
|
||||
api_routes.households_shopping_lists_item_id_recipe(shopping_list.id),
|
||||
json=utils.jsonify([ShoppingListAddRecipeParamsBulk(recipe_id=recipe_a.id).model_dump()]),
|
||||
headers=unique_user.token,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get the shopping list and verify all ingredients from a, b, and c are flattened
|
||||
response = api_client.get(
|
||||
api_routes.households_shopping_lists_item_id(shopping_list.id),
|
||||
headers=unique_user.token,
|
||||
)
|
||||
shopping_list_data = utils.assert_deserialize(response, 200)
|
||||
|
||||
# Should have 3 items: one from recipe_a, one from recipe_b, and one from recipe_c
|
||||
assert len(shopping_list_data["listItems"]) == 3
|
||||
|
||||
# Verify each ingredient is present with the correct quantity
|
||||
found_ingredients = {
|
||||
food_a.name: False,
|
||||
food_b.name: False,
|
||||
food_c.name: False,
|
||||
}
|
||||
|
||||
for item in shopping_list_data["listItems"]:
|
||||
if food_a.name in item["note"]:
|
||||
assert item["quantity"] == 3
|
||||
found_ingredients[food_a.name] = True
|
||||
elif food_b.name in item["note"]:
|
||||
assert item["quantity"] == 2
|
||||
found_ingredients[food_b.name] = True
|
||||
elif food_c.name in item["note"]:
|
||||
assert item["quantity"] == 1
|
||||
found_ingredients[food_c.name] = True
|
||||
|
||||
# Ensure all ingredients were found
|
||||
assert all(found_ingredients.values()), f"Missing ingredients: {found_ingredients}"
|
||||
|
||||
# Verify recipe reference
|
||||
refs = shopping_list_data["recipeReferences"]
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["recipeId"] == str(recipe_a.id)
|
||||
assert refs[0]["recipeQuantity"] == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_private_household", [True, False])
|
||||
@pytest.mark.parametrize("household_lock_recipe_edits", [True, False])
|
||||
def test_shopping_lists_add_cross_household_recipe(
|
||||
|
||||
@@ -23,6 +23,7 @@ from mealie.pkgs.safehttp.transport import AsyncSafeTransport
|
||||
from mealie.schema.cookbook.cookbook import SaveCookBook
|
||||
from mealie.schema.recipe.recipe import Recipe, RecipeCategory, RecipeSummary, RecipeTag
|
||||
from mealie.schema.recipe.recipe_category import CategorySave, TagSave
|
||||
from mealie.schema.recipe.recipe_ingredient import RecipeIngredient, SaveIngredientFood
|
||||
from mealie.schema.recipe.recipe_notes import RecipeNote
|
||||
from mealie.schema.recipe.recipe_tool import RecipeToolSave
|
||||
from mealie.services.recipe.recipe_data_service import RecipeDataService
|
||||
@@ -512,6 +513,251 @@ def test_update_many(api_client: TestClient, unique_user: TestUser, use_patch: b
|
||||
assert get_response.json()["slug"] == updated_recipe_data["slug"]
|
||||
|
||||
|
||||
def test_recipe_recursion_valid_linear_chain(api_client: TestClient, unique_user: TestUser):
|
||||
"""Test that valid deep nesting without cycles is allowed (a -> b -> c)."""
|
||||
database = unique_user.repos
|
||||
|
||||
food = database.ingredient_foods.create(
|
||||
SaveIngredientFood(
|
||||
name=random_string(10),
|
||||
group_id=unique_user.group_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Create recipe_c with just a food ingredient (base recipe)
|
||||
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="", food=food),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Create recipe_b that references recipe_c (c -> b)
|
||||
recipe_b = 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_c),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Update recipe_a to reference recipe_b (b -> a, creating chain c -> b -> 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),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
recipe_url = api_routes.recipes_slug(recipe_a.slug)
|
||||
response = api_client.get(recipe_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipe_data = json.loads(response.text)
|
||||
|
||||
recipe_data["recipeIngredient"].append(
|
||||
{
|
||||
"note": "",
|
||||
"referencedRecipe": {"id": str(recipe_b.id)},
|
||||
}
|
||||
)
|
||||
response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_recipe_recursion_cycle_two_level(api_client: TestClient, unique_user: TestUser):
|
||||
"""Test that two-level cycles (a -> b -> a) are detected and rejected."""
|
||||
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 that references recipe_a (a -> b)
|
||||
recipe_b = 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),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Try to update recipe_a to reference recipe_b, creating a cycle (b -> a)
|
||||
recipe_url = api_routes.recipes_slug(recipe_a.slug)
|
||||
response = api_client.get(recipe_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipe_data = json.loads(response.text)
|
||||
|
||||
recipe_data["recipeIngredient"].append(
|
||||
{
|
||||
"note": "",
|
||||
"referencedRecipe": {"id": str(recipe_b.id)},
|
||||
}
|
||||
)
|
||||
response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
|
||||
assert response.status_code == 400
|
||||
assert "cannot reference itself" in response.text.lower()
|
||||
|
||||
|
||||
def test_recipe_recursion_cycle_three_level(api_client: TestClient, unique_user: TestUser):
|
||||
"""Test that three-level cycles (a -> b -> c -> a) are detected and rejected."""
|
||||
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 that references recipe_a (a -> b)
|
||||
recipe_b = 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 that references recipe_b (b -> c, creating chain a -> b -> c)
|
||||
recipe_c = 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),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Try to update recipe_a to reference recipe_c, creating a cycle (c -> a)
|
||||
recipe_url = api_routes.recipes_slug(recipe_a.slug)
|
||||
response = api_client.get(recipe_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipe_data = json.loads(response.text)
|
||||
|
||||
recipe_data["recipeIngredient"].append(
|
||||
{
|
||||
"note": "",
|
||||
"referencedRecipe": {"id": str(recipe_c.id)},
|
||||
}
|
||||
)
|
||||
response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token)
|
||||
assert response.status_code == 400
|
||||
assert "cannot reference itself" in response.text.lower()
|
||||
|
||||
|
||||
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
|
||||
|
||||
food = database.ingredient_foods.create(
|
||||
SaveIngredientFood(
|
||||
name=random_string(10),
|
||||
group_id=unique_user.group_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 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="", food=food),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Create recipe_a that references recipe_b
|
||||
recipe_a = database.recipes.create(
|
||||
Recipe(
|
||||
name=random_string(10),
|
||||
user_id=unique_user.user_id,
|
||||
group_id=unique_user.group_id,
|
||||
recipe_ingredient=[
|
||||
RecipeIngredient(note="ingredient 1", referenced_recipe=recipe_b),
|
||||
RecipeIngredient(note="ingredient 2", food=food),
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Verify recipe_a has the reference to recipe_b
|
||||
recipe_a_url = api_routes.recipes_slug(recipe_a.slug)
|
||||
response = api_client.get(recipe_a_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipe_a_data = json.loads(response.text)
|
||||
assert len(recipe_a_data["recipeIngredient"]) == 2
|
||||
assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"] is not None
|
||||
assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"]["id"] == str(recipe_b.id)
|
||||
|
||||
# Delete recipe_b
|
||||
recipe_b_url = api_routes.recipes_slug(recipe_b.slug)
|
||||
response = api_client.delete(recipe_b_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify recipe_b is deleted
|
||||
response = api_client.get(recipe_b_url, headers=unique_user.token)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Verify recipe_a still exists and can be retrieved
|
||||
response = api_client.get(recipe_a_url, headers=unique_user.token)
|
||||
assert response.status_code == 200
|
||||
recipe_a_data = json.loads(response.text)
|
||||
|
||||
# The ingredient with the deleted reference should still exist but with no valid reference
|
||||
assert len(recipe_a_data["recipeIngredient"]) == 2
|
||||
assert recipe_a_data["recipeIngredient"][0]["note"] == "ingredient 1"
|
||||
assert recipe_a_data["recipeIngredient"][1]["note"] == "ingredient 2"
|
||||
# The referenced recipe should be None or not present since it was deleted
|
||||
assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"] is None
|
||||
|
||||
|
||||
def test_duplicate(api_client: TestClient, unique_user: TestUser):
|
||||
recipe_data = recipe_test_data[0]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user