Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse Brault
6eead31193 MME-13 WIP hibernate config annotations and migration. 2026-02-12 13:10:28 -06:00
Jesse Brault
d2beb2af0f MME-13 Add delete recipe endpoint and tests. 2026-02-11 18:33:41 -06:00
9 changed files with 165 additions and 9 deletions

View File

@ -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();

View File

@ -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());
}
}

View File

@ -52,11 +52,10 @@ public class Recipe {
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@OneToMany
@JoinColumn(name = "recipe_id")
@OneToMany(mappedBy = "recipe", orphanRemoval = true, cascade = CascadeType.ALL)
private Set<RecipeStar> stars = new HashSet<>();
@OneToMany(mappedBy = "recipe")
@OneToMany(mappedBy = "recipe", orphanRemoval = true, cascade = CascadeType.ALL)
private Set<RecipeComment> comments = new HashSet<>();
@Column(nullable = false)

View File

@ -74,4 +74,6 @@ public interface RecipeRepository extends JpaRepository<Recipe, Integer> {
@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);
}

View File

@ -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)
);

View File

@ -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) {

View File

@ -118,6 +118,16 @@ public class RecipesController {
}
}
@DeleteMapping("/{username}/{slug}")
public ResponseEntity<Void> 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<RecipeStar> addStar(
@PathVariable String username,

View File

@ -1,9 +1,8 @@
package app.mealsmadeeasy.api.recipe.star;
import jakarta.persistence.Column;
import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
import app.mealsmadeeasy.api.recipe.Recipe;
import app.mealsmadeeasy.api.user.User;
import jakarta.persistence.*;
import lombok.Data;
import java.time.OffsetDateTime;
@ -16,6 +15,16 @@ public final class RecipeStar {
@EmbeddedId
private RecipeStarId id;
@ManyToOne
@MapsId("ownerId")
@JoinColumn(name = "owner_id", nullable = false, updatable = false)
private User owner;
@ManyToOne
@MapsId("recipeId")
@JoinColumn(name = "recipe_id", nullable = false, updatable = false)
private Recipe recipe;
@Column(nullable = false, updatable = false)
private OffsetDateTime timestamp = OffsetDateTime.now();

View File

@ -0,0 +1,47 @@
ALTER TABLE user_granted_authority DROP CONSTRAINT user_granted_authority_user_id_fkey;
ALTER TABLE user_granted_authority ADD FOREIGN KEY (user_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE refresh_token DROP CONSTRAINT refresh_token_owner_id_fkey;
ALTER TABLE refresh_token ADD FOREIGN KEY (owner_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE image DROP CONSTRAINT image_owner_id_fkey;
ALTER TABLE image ADD FOREIGN KEY (owner_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE image_viewer DROP CONSTRAINT image_viewer_image_id_fkey;
ALTER TABLE image_viewer ADD FOREIGN KEY (image_id) REFERENCES image ON DELETE CASCADE;
ALTER TABLE image_viewer DROP CONSTRAINT image_viewer_viewer_id_fkey;
ALTER TABLE image_viewer ADD FOREIGN KEY (viewer_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE recipe DROP CONSTRAINT recipe_owner_id_fkey;
ALTER TABLE recipe ADD FOREIGN KEY (owner_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE recipe DROP CONSTRAINT recipe_main_image_id_fkey;
ALTER TABLE recipe ADD FOREIGN KEY (main_image_id) REFERENCES image ON DELETE SET NULL;
ALTER TABLE recipe_viewer DROP CONSTRAINT recipe_viewer_recipe_id_fkey;
ALTER TABLE recipe_viewer ADD FOREIGN KEY (recipe_id) REFERENCES recipe ON DELETE CASCADE;
ALTER TABLE recipe_viewer DROP CONSTRAINT recipe_viewer_viewer_id_fkey;
ALTER TABLE recipe_viewer ADD FOREIGN KEY (viewer_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE recipe_comment DROP CONSTRAINT recipe_comment_owner_id_fkey;
ALTER TABLE recipe_comment ADD FOREIGN KEY (owner_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE recipe_comment DROP CONSTRAINT recipe_comment_recipe_id_fkey;
ALTER TABLE recipe_comment ADD FOREIGN KEY (recipe_id) REFERENCES recipe ON DELETE CASCADE;
ALTER TABLE recipe_star DROP CONSTRAINT recipe_star_recipe_id_fkey;
ALTER TABLE recipe_star ADD FOREIGN KEY (recipe_id) REFERENCES recipe ON DELETE CASCADE;
ALTER TABLE recipe_star DROP CONSTRAINT recipe_star_owner_id_fkey;
ALTER TABLE recipe_star ADD FOREIGN KEY (owner_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE recipe_draft DROP CONSTRAINT recipe_draft_owner_id_fkey;
ALTER TABLE recipe_draft ADD FOREIGN KEY (owner_id) REFERENCES "user" ON DELETE CASCADE;
ALTER TABLE recipe_draft DROP CONSTRAINT recipe_draft_main_image_id_fkey;
ALTER TABLE recipe_draft ADD FOREIGN KEY (main_image_id) REFERENCES image ON DELETE SET NULL;
ALTER TABLE file DROP CONSTRAINT file_owner_id_fkey;
ALTER TABLE file ADD FOREIGN KEY (owner_id) REFERENCES "user" ON DELETE CASCADE;