From c029a639fb66ec27af2a35ae3bf612ef4acd1159 Mon Sep 17 00:00:00 2001 From: harshitlarl <64593254+harshitlarl@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:38:48 +0530 Subject: [PATCH] fix: preserve stored recipe slugs during hydration (#7294) Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com> Co-authored-by: Michael Genson --- mealie/repos/repository_recipes.py | 8 ++- mealie/schema/recipe/recipe.py | 2 +- .../test_public_recipes.py | 4 +- .../user_recipe_tests/test_recipe_crud.py | 54 +++++++++++++++++++ tests/unit_tests/schema_tests/test_recipe.py | 13 +++++ 5 files changed, 77 insertions(+), 4 deletions(-) diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py index aa7512fd4..7605f951c 100644 --- a/mealie/repos/repository_recipes.py +++ b/mealie/repos/repository_recipes.py @@ -203,13 +203,19 @@ class RepositoryRecipes(HouseholdRepositoryGeneric[Recipe, RecipeModel]): def update(self, match_value: str | int | UUID4, new_data: dict | Recipe) -> Recipe: new_data = new_data if isinstance(new_data, dict) else new_data.model_dump() + entry = self._query_one(match_value=match_value) + + if new_name := new_data.get("name"): + new_data["slug"] = entry.slug if new_name == entry.name else create_recipe_slug(new_name) # Handle explicit group_id injection for related items that require it for organizer_field in ["tags", "recipe_category", "tools"]: for organizer in new_data.get(organizer_field, []): organizer["group_id"] = self.group_id - return super().update(match_value, new_data) + entry.update(session=self.session, **new_data) + self.session.commit() + return self.schema.model_validate(entry) def page_all( # type: ignore self, diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py index a41d505ac..24690c798 100644 --- a/mealie/schema/recipe/recipe.py +++ b/mealie/schema/recipe/recipe.py @@ -240,7 +240,7 @@ class Recipe(RecipeSummary): @field_validator("slug", mode="before") def validate_slug(slug: str, info: ValidationInfo): - if not info.data.get("name"): + if slug or not info.data.get("name"): return slug return create_recipe_slug(info.data["name"]) diff --git a/tests/integration_tests/public_explorer_tests/test_public_recipes.py b/tests/integration_tests/public_explorer_tests/test_public_recipes.py index 75b814890..b727d78cb 100644 --- a/tests/integration_tests/public_explorer_tests/test_public_recipes.py +++ b/tests/integration_tests/public_explorer_tests/test_public_recipes.py @@ -98,8 +98,8 @@ def test_get_all_public_recipes( @pytest.mark.parametrize( "query_filter, recipe_data, should_fetch", [ - ('slug = "mypublicslug"', {"slug": "mypublicslug"}, True), - ('slug = "mypublicslug"', {"slug": "notmypublicslug"}, False), + ('slug = "mypublicslug"', {"slug": "mypublicslug", "name": "mypublicslug"}, True), + ('slug = "mypublicslug"', {"slug": "notmypublicslug", "name": "notmypublicslug"}, False), ("settings.public = FALSE", {}, False), ("settings.public <> TRUE", {}, False), ], 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 334eaa1ec..467cab8b1 100644 --- a/tests/integration_tests/user_recipe_tests/test_recipe_crud.py +++ b/tests/integration_tests/user_recipe_tests/test_recipe_crud.py @@ -1357,6 +1357,60 @@ def test_recipe_crud_404(api_client: TestClient, unique_user: TestUser): assert response.status_code == 404 +def test_patch_recipe_after_name_changes_without_slug_update(api_client: TestClient, unique_user: TestUser): + original_name = "Nourish Bowls (Zuppa Copycat)" + translated_name = "Bols nourrissants (copie de Zuppa)" + + response = api_client.post(api_routes.recipes, json={"name": original_name}, headers=unique_user.token) + assert response.status_code == 201 + original_slug = response.json() + + session = unique_user.repos.session + recipe = session.query(RecipeModel).filter(RecipeModel.slug == original_slug).one() + recipe.name = translated_name + session.commit() + + response = api_client.get(api_routes.recipes_slug(original_slug), headers=unique_user.token) + assert response.status_code == 200 + + recipe_payload = response.json() + assert recipe_payload["name"] == translated_name + assert recipe_payload["slug"] == original_slug + + response = api_client.patch( + api_routes.recipes_slug(original_slug), + json={"description": "Translated without changing the stored slug"}, + headers=unique_user.token, + ) + assert response.status_code == 200 + + patched_recipe = response.json() + assert patched_recipe["slug"] == original_slug + assert patched_recipe["description"] == "Translated without changing the stored slug" + + +def test_put_recipe_name_change_updates_slug(api_client: TestClient, unique_user: TestUser): + original_name = "Original Recipe Name" + renamed_name = "Renamed Recipe Name" + + response = api_client.post(api_routes.recipes, json={"name": original_name}, headers=unique_user.token) + assert response.status_code == 201 + original_slug = response.json() + + response = api_client.get(api_routes.recipes_slug(original_slug), headers=unique_user.token) + assert response.status_code == 200 + + recipe_payload = response.json() + recipe_payload["name"] = renamed_name + + response = api_client.put(api_routes.recipes_slug(original_slug), json=recipe_payload, headers=unique_user.token) + assert response.status_code == 200 + + renamed_recipe = response.json() + assert renamed_recipe["slug"] == slugify(renamed_name) + assert renamed_recipe["name"] == renamed_name + + def test_create_recipe_same_name(api_client: TestClient, unique_user: TestUser): slug = random_string(10) diff --git a/tests/unit_tests/schema_tests/test_recipe.py b/tests/unit_tests/schema_tests/test_recipe.py index fd4ba0ba4..3e78a2e24 100644 --- a/tests/unit_tests/schema_tests/test_recipe.py +++ b/tests/unit_tests/schema_tests/test_recipe.py @@ -61,3 +61,16 @@ def test_recipe_string_sanitation(field: str, val: Any, expected: Any): ) assert getattr(recipe, field) == expected + + +def test_recipe_preserves_existing_slug(): + recipe = RecipeSummary( + id=uuid4(), + user_id=uuid4(), + household_id=uuid4(), + group_id=uuid4(), + name="Bols nourrissants (copie de Zuppa)", + slug="nourish-bowls-zuppa-copycat", + ) + + assert recipe.slug == "nourish-bowls-zuppa-copycat"