From d2beb2af0fb01c5e31ac5199dcc93bc39e2be85e Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Wed, 11 Feb 2026 18:33:41 -0600 Subject: [PATCH] MME-13 Add delete recipe endpoint and tests. --- .../api/recipe/RecipeServiceTests.java | 27 +++++++++ .../api/recipe/RecipesControllerTests.java | 55 +++++++++++++++++++ .../api/recipe/RecipeRepository.java | 2 + .../api/recipe/RecipeSecurity.java | 5 +- .../api/recipe/RecipeService.java | 6 ++ .../api/recipe/RecipesController.java | 10 ++++ 6 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java index 7f7f7af..0e5e8dd 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeServiceTests.java @@ -362,6 +362,33 @@ public class RecipeServiceTests { assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe(toDelete.getId(), notOwner)); } + @Test + public void deleteRecipeByUsernameAndSlug() { + final User owner = this.seedUser(); + final Recipe toDelete = this.createTestRecipe(owner); + this.recipeService.deleteRecipe(owner.getUsername(), toDelete.getSlug(), owner); + assertThrows(NoSuchEntityWithIdException.class, () -> this.recipeService.getById(toDelete.getId(), owner)); + } + + @Test + public void deleteRecipeByUsernameAndSlug_throwsIfNullDeleter() { + final User owner = this.seedUser(); + final Recipe recipe = this.createTestRecipe(owner); + assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe( + owner.getUsername(), recipe.getSlug(), null + )); + } + + @Test + public void deleteRecipeByUsernameAndSlug_throwsIfNotOwner() { + final User owner = this.seedUser(); + final User wrong = this.seedUser(); + final Recipe recipe = this.createTestRecipe(owner); + assertThrows(AccessDeniedException.class, () -> this.recipeService.deleteRecipe( + owner.getUsername(), recipe.getSlug(), wrong + )); + } + @Test public void createDraftReturnsDefaultRecipeDraft() { final User owner = this.seedUser(); diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java index 1a7d5c2..51e573d 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipesControllerTests.java @@ -328,4 +328,59 @@ public class RecipesControllerTests { .andExpect(status().isNoContent()); } + @Test + public void deleteRecipeByOwner_returnsNoContent() throws Exception { + final User owner = this.seedUser(); + final Recipe recipe = this.createTestRecipe(owner, false); + this.mockMvc.perform( + delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()) + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isNoContent()); + } + + @Test + public void deletePrivateRecipe_noAuth_returnsUnauthorized() throws Exception { + final User owner = this.seedUser(); + final Recipe recipe = this.createTestRecipe(owner, false); + this.mockMvc.perform( + delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()) + ) + .andExpect(status().isUnauthorized()); + } + + @Test + public void deletePublicRecipe_noAuth_returnsUnauthorized() throws Exception { + final User owner = this.seedUser(); + final Recipe recipe = this.createTestRecipe(owner, true); + this.mockMvc.perform( + delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()) + ) + .andExpect(status().isUnauthorized()); + } + + @Test + public void deletePublicRecipe_wrongAuth_returnsForbidden() throws Exception { + final User owner = this.seedUser(); + final User wrong = this.seedUser(); + final Recipe recipe = this.createTestRecipe(owner, true); + this.mockMvc.perform( + delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()) + .header("Authorization", "Bearer " + this.getAccessToken(wrong)) + ) + .andExpect(status().isForbidden()); + } + + @Test + public void deletePrivateRecipe_wrongAuth_returnsForbidden() throws Exception { + final User owner = this.seedUser(); + final User wrong = this.seedUser(); + final Recipe recipe = this.createTestRecipe(owner, false); + this.mockMvc.perform( + delete("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()) + .header("Authorization", "Bearer " + this.getAccessToken(wrong)) + ) + .andExpect(status().isForbidden()); + } + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java index 1ee03e8..de5497f 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeRepository.java @@ -74,4 +74,6 @@ public interface RecipeRepository extends JpaRepository { @Query("SELECT count(r) FROM Recipe r WHERE ?1 MEMBER OF r.viewers OR r.isPublic IS TRUE") int countViewableBy(User viewer); + void deleteRecipeByOwnerUsernameAndSlug(String username, String slug); + } diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java index 427893f..1e31b52 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeSecurity.java @@ -17,11 +17,12 @@ public class RecipeSecurity { this.recipeRepository = recipeRepository; } - public boolean isOwner(Recipe recipe, User user) { + public boolean isOwner(Recipe recipe, @Nullable User user) { + if (user == null) return false; return recipe.getOwner() != null && recipe.getOwner().getId().equals(user.getId()); } - public boolean isOwner(Integer recipeId, User user) { + public boolean isOwner(Integer recipeId, @Nullable User user) { final Recipe recipe = this.recipeRepository.findById(recipeId).orElseThrow( () -> new NoSuchEntityWithIdException(Recipe.class, recipeId) ); diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index 88a372e..bb771bb 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -219,6 +219,12 @@ public class RecipeService { this.recipeRepository.deleteById(id); } + @PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #deleter)") + @Transactional + public void deleteRecipe(String username, String slug, User deleter) { + this.recipeRepository.deleteRecipeByOwnerUsernameAndSlug(username, slug); + } + @PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)") @Contract("_, _, null -> null") public @Nullable Boolean isStarer(String username, String slug, @Nullable User viewer) { diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java index 5ba061b..17622f6 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipesController.java @@ -118,6 +118,16 @@ public class RecipesController { } } + @DeleteMapping("/{username}/{slug}") + public ResponseEntity deleteRecipe( + @AuthenticationPrincipal User principal, + @PathVariable String username, + @PathVariable String slug + ) { + this.recipeService.deleteRecipe(username, slug, principal); + return ResponseEntity.noContent().build(); + } + @PostMapping("/{username}/{slug}/star") public ResponseEntity addStar( @PathVariable String username,