diff --git a/mealie/db/models/_model_utils/auto_init.py b/mealie/db/models/_model_utils/auto_init.py index 6b36faded..26e914af7 100644 --- a/mealie/db/models/_model_utils/auto_init.py +++ b/mealie/db/models/_model_utils/auto_init.py @@ -168,14 +168,20 @@ def auto_init(): # sourcery no-metrics setattr(self, key, instance) elif relation_dir == MANYTOONE and not use_list: + lookup_attr = get_attr if isinstance(val, dict): - val = val.get(get_attr) + # Prefer primary key when provided to avoid ambiguous alternate-key lookups + if "id" in val and val["id"] is not None: + lookup_attr = "id" + val = val["id"] + else: + val = val.get(get_attr) if val is None: - raise ValueError(f"Expected 'id' to be provided for {key}") + raise ValueError(f"Expected '{lookup_attr}' to be provided for {key}") if isinstance(val, str | int | UUID): - stmt = select(relation_cls).filter_by(**{get_attr: val}) + stmt = select(relation_cls).filter_by(**{lookup_attr: val}) instance = session.execute(stmt).scalars().one_or_none() setattr(self, key, instance) else: diff --git a/mealie/services/recipe/recipe_service.py b/mealie/services/recipe/recipe_service.py index bf5e45c1b..1e38a388a 100644 --- a/mealie/services/recipe/recipe_service.py +++ b/mealie/services/recipe/recipe_service.py @@ -463,9 +463,34 @@ class RecipeService(RecipeServiceBase): return recipe + def _resolve_ingredient_sub_recipes(self, update_data: Recipe) -> Recipe: + """Resolve all referenced_recipe slugs to IDs within the current group.""" + if not update_data.recipe_ingredient: + return update_data + + for ingredient in update_data.recipe_ingredient: + if ingredient.referenced_recipe: + ref = ingredient.referenced_recipe + # If no id, resolve by slug + if not ref.id and ref.slug: + recipe = self.group_recipes.get_by_slug(self.user.group_id, ref.slug) + if not recipe: + raise exceptions.NoEntryFound(f"Referenced recipe '{ref.slug}' not found in this group") + ref.id = recipe.id + # If id is provided, verify it belongs to this group + elif ref.id: + recipe = self.group_recipes.get_one(ref.id, key="id") + if not recipe: + raise exceptions.NoEntryFound(f"Referenced recipe with id '{ref.id}' not found in this group") + + return update_data + def update_one(self, slug_or_id: str | UUID, update_data: Recipe) -> Recipe: recipe = self._pre_update_check(slug_or_id, update_data) + # Resolve sub-recipe references before passing to repository + update_data = self._resolve_ingredient_sub_recipes(update_data) + new_data = self.group_recipes.update(recipe.slug, update_data) self.check_assets(new_data, recipe.slug) return new_data 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 15b643b43..0370dbbaf 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -882,6 +882,117 @@ def test_recipe_reference_deleted(api_client: TestClient, unique_user: TestUser) assert recipe_a_data["recipeIngredient"][0]["referencedRecipe"] is None +def test_sub_recipe_resolves_within_same_group(api_client: TestClient, unique_user: TestUser, g2_user: TestUser): + """ + Test that when two groups have recipes with the same slug, updating a recipe + with a sub-recipe reference by slug correctly resolves to the current group's recipe. + + This prevents the MultipleResultsFound error when slugs are duplicated across groups. + """ + # Create a recipe with the same slug in both groups + shared_slug = random_string(10) + + # Create sub-recipe in group 1 (unique_user's group) + sub_recipe_g1 = unique_user.repos.recipes.create( + Recipe( + name=shared_slug, + user_id=unique_user.user_id, + group_id=unique_user.group_id, + ) + ) + + # Create sub-recipe in group 2 (g2_user's group) with the same name/slug + sub_recipe_g2 = g2_user.repos.recipes.create( + Recipe( + name=shared_slug, + user_id=g2_user.user_id, + group_id=g2_user.group_id, + ) + ) + + # Verify both recipes have the same slug but different IDs + assert sub_recipe_g1.slug == sub_recipe_g2.slug + assert sub_recipe_g1.id != sub_recipe_g2.id + + # Create a parent recipe in group 1 + parent_recipe = unique_user.repos.recipes.create( + Recipe( + name=random_string(10), + user_id=unique_user.user_id, + group_id=unique_user.group_id, + ) + ) + + # Get the parent recipe via API + recipe_url = api_routes.recipes_slug(parent_recipe.slug) + response = api_client.get(recipe_url, headers=unique_user.token) + assert response.status_code == 200 + recipe_data = response.json() + + # Update the parent recipe to reference the sub-recipe BY SLUG (not ID) + # This is the scenario that previously caused MultipleResultsFound + recipe_data["recipeIngredient"] = [ + { + "note": "Sub-recipe ingredient", + "referencedRecipe": {"slug": shared_slug}, + } + ] + + # This should succeed and resolve to group 1's sub-recipe + response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token) + assert response.status_code == 200 + + # Verify the referenced recipe is the one from group 1, not group 2 + updated_recipe = response.json() + assert len(updated_recipe["recipeIngredient"]) == 1 + referenced = updated_recipe["recipeIngredient"][0].get("referencedRecipe") + assert referenced is not None + assert referenced["id"] == str(sub_recipe_g1.id) + + +def test_sub_recipe_not_found_in_other_group(api_client: TestClient, unique_user: TestUser, g2_user: TestUser): + """ + Test that referencing a sub-recipe that only exists in another group fails. + """ + # Create a sub-recipe ONLY in group 2 + sub_recipe_slug = random_string(10) + g2_user.repos.recipes.create( + Recipe( + name=sub_recipe_slug, + user_id=g2_user.user_id, + group_id=g2_user.group_id, + ) + ) + + # Create a parent recipe in group 1 + parent_recipe = unique_user.repos.recipes.create( + Recipe( + name=random_string(10), + user_id=unique_user.user_id, + group_id=unique_user.group_id, + ) + ) + + # Get the parent recipe via API + recipe_url = api_routes.recipes_slug(parent_recipe.slug) + response = api_client.get(recipe_url, headers=unique_user.token) + assert response.status_code == 200 + recipe_data = response.json() + + # Try to reference the sub-recipe from group 2 by slug + recipe_data["recipeIngredient"] = [ + { + "note": "Sub-recipe from other group", + "referencedRecipe": {"slug": sub_recipe_slug}, + } + ] + + # This should fail because the sub-recipe doesn't exist in group 1 + response = api_client.put(recipe_url, json=recipe_data, headers=unique_user.token) + assert response.status_code == 404 + assert response.json()["detail"]["message"] == "No Entry Found" + + def test_duplicate(api_client: TestClient, unique_user: TestUser): recipe_data = recipe_test_data[0]