Added update method to controller and related implementation.

This commit is contained in:
Jesse Brault 2024-08-16 11:38:00 -05:00
parent 9b82e549ca
commit 3a7c0f5b1d
12 changed files with 248 additions and 70 deletions

View File

@ -4,14 +4,18 @@ import app.mealsmadeeasy.api.auth.AuthService;
import app.mealsmadeeasy.api.auth.LoginDetails;
import app.mealsmadeeasy.api.auth.LoginException;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.servlet.MockMvc;
@ -40,6 +44,9 @@ public class RecipeControllerTests {
@Autowired
private AuthService authService;
@Autowired
private ObjectMapper objectMapper;
private User createTestUser(String username) {
try {
return this.userService.createUser(username, username + "@test.com", "test");
@ -187,6 +194,65 @@ public class RecipeControllerTests {
.andExpect(jsonPath("$.content", hasSize(3)));
}
private String getUpdateBody() throws JsonProcessingException {
final RecipeUpdateSpec spec = new RecipeUpdateSpec();
spec.setTitle("Updated Test Recipe");
spec.setPreparationTime(15);
spec.setCookingTime(30);
spec.setTotalTime(45);
spec.setRawText("# Hello, Updated World!");
spec.setIsPublic(true);
return this.objectMapper.writeValueAsString(spec);
}
@Test
@DirtiesContext
public void updateRecipe() throws Exception {
final User owner = this.createTestUser("owner");
final Recipe recipe = this.createTestRecipe(owner, false);
final String accessToken = this.getAccessToken(owner);
final String body = this.getUpdateBody();
this.mockMvc.perform(
post("/recipes/{username}/{slug}", owner.getUsername(), recipe.getSlug())
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.recipe.id").value(recipe.getId()))
.andExpect(jsonPath("$.recipe.title").value("Updated Test Recipe"))
.andExpect(jsonPath("$.recipe.preparationTime").value(15))
.andExpect(jsonPath("$.recipe.cookingTime").value(30))
.andExpect(jsonPath("$.recipe.totalTime").value(45))
.andExpect(jsonPath("$.recipe.text").value("<h1>Hello, Updated World!</h1>"))
.andExpect(jsonPath("$.recipe.rawText").doesNotExist())
.andExpect(jsonPath("$.recipe.owner.id").value(owner.getId()))
.andExpect(jsonPath("$.recipe.owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.recipe.starCount").value(0))
.andExpect(jsonPath("$.recipe.viewerCount").value(0))
.andExpect(jsonPath("$.recipe.isPublic").value(true))
.andExpect(jsonPath("$.isStarred").value(false))
.andExpect(jsonPath("$.isOwner").value(true));
}
@Test
@DirtiesContext
public void updateRecipeIncludeRawText() throws Exception {
final User owner = this.createTestUser("owner");
final Recipe recipe = this.createTestRecipe(owner, false);
final String accessToken = this.getAccessToken(owner);
final String body = this.getUpdateBody();
this.mockMvc.perform(
post("/recipes/{username}/{slug}", owner.getUsername(), recipe.getSlug())
.header("Authorization", "Bearer " + accessToken)
.param("includeRawText", "true")
.contentType(MediaType.APPLICATION_JSON)
.content(body)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.recipe.rawText").value("# Hello, Updated World!"));
}
@Test
@DirtiesContext
public void addStarToRecipe() throws Exception {

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
@ -162,13 +163,18 @@ public class RecipeServiceTests {
@Test
@DirtiesContext
public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() throws RecipeException {
public void getByIdWithStarsOkayWhenPublicRecipeWithViewer() throws RecipeException, ImageException {
final User owner = this.createTestUser("recipeOwner");
final User viewer = this.createTestUser("viewer");
final Recipe notYetPublicRecipe = this.createTestRecipe(owner);
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
updateSpec.setPublic(true);
final Recipe publicRecipe = this.recipeService.update(notYetPublicRecipe.getId(), updateSpec, owner);
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(notYetPublicRecipe);
updateSpec.setIsPublic(true);
final Recipe publicRecipe = this.recipeService.update(
notYetPublicRecipe.getOwner().getUsername(),
notYetPublicRecipe.getSlug(),
updateSpec,
owner
);
assertDoesNotThrow(() -> this.recipeService.getByIdWithStars(publicRecipe.getId(), viewer));
}
@ -304,7 +310,7 @@ public class RecipeServiceTests {
@Test
@DirtiesContext
public void updateRawText() throws RecipeException {
public void updateRawText() throws RecipeException, ImageException {
final User owner = this.createTestUser("recipeOwner");
final RecipeCreateSpec createSpec = new RecipeCreateSpec();
createSpec.setSlug("my-recipe");
@ -312,9 +318,14 @@ public class RecipeServiceTests {
createSpec.setRawText("# A Heading");
Recipe recipe = this.recipeService.create(owner, createSpec);
final String newRawText = "# A Heading\n## A Subheading";
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec();
final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(recipe);
updateSpec.setRawText(newRawText);
recipe = this.recipeService.update(recipe.getId(), updateSpec, owner);
recipe = this.recipeService.update(
recipe.getOwner().getUsername(),
recipe.getSlug(),
updateSpec,
owner
);
assertThat(recipe.getRawText(), is(newRawText));
}
@ -328,7 +339,12 @@ public class RecipeServiceTests {
updateSpec.setRawText("should fail");
assertThrows(
AccessDeniedException.class,
() -> this.recipeService.update(recipe.getId(), updateSpec, notOwner)
() -> this.recipeService.update(
recipe.getOwner().getUsername(),
recipe.getSlug(),
updateSpec,
notOwner
)
);
}

View File

@ -3,7 +3,10 @@ package app.mealsmadeeasy.api.image;
public class ImageException extends Exception {
public enum Type {
INVALID_ID, IMAGE_NOT_FOUND, UNKNOWN_MIME_TYPE
INVALID_ID,
INVALID_USERNAME_OR_FILENAME,
IMAGE_NOT_FOUND,
UNKNOWN_MIME_TYPE
}
private final Type type;

View File

@ -17,6 +17,7 @@ public interface ImageService {
Image getById(long id, @Nullable User viewer) throws ImageException;
Image getByOwnerAndFilename(User owner, String filename, User viewer) throws ImageException;
Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException;
InputStream getImageContent(Image image, @Nullable User viewer) throws IOException;
List<Image> getImagesOwnedBy(User user);

View File

@ -17,4 +17,7 @@ public interface S3ImageRepository extends JpaRepository<S3ImageEntity, Long> {
List<S3ImageEntity> findAllByOwner(UserEntity owner);
Optional<S3ImageEntity> findByOwnerAndUserFilename(UserEntity owner, String filename);
@Query("SELECT image from Image image WHERE image.owner.username = ?1 AND image.userFilename = ?2")
Optional<S3ImageEntity> findByOwnerUsernameAndFilename(String username, String filename);
}

View File

@ -139,6 +139,17 @@ public class S3ImageService implements ImageService {
));
}
@Override
@PostAuthorize("@imageSecurity.isViewableBy(returnObject, #viewer)")
public Image getByUsernameAndFilename(String username, String filename, User viewer) throws ImageException {
return this.imageRepository.findByOwnerUsernameAndFilename(username, filename).orElseThrow(
() -> new ImageException(
ImageException.Type.INVALID_USERNAME_OR_FILENAME,
"No such Image for username " + username + " and filename " + filename
)
);
}
@Override
@PreAuthorize("@imageSecurity.isViewableBy(#image, #viewer)")
public InputStream getImageContent(Image image, User viewer) throws IOException {

View File

@ -1,5 +1,7 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.star.RecipeStar;
import app.mealsmadeeasy.api.recipe.star.RecipeStarService;
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
@ -42,6 +44,14 @@ public class RecipeController {
));
}
private Map<String, Object> getFullViewWrapper(String username, String slug, FullRecipeView view, @Nullable User viewer) {
Map<String, Object> wrapper = new HashMap<>();
wrapper.put("recipe", view);
wrapper.put("isStarred", this.recipeService.isStarer(username, slug, viewer));
wrapper.put("isOwner", this.recipeService.isOwner(username, slug, viewer));
return wrapper;
}
@GetMapping("/{username}/{slug}")
public ResponseEntity<Map<String, Object>> getByUsernameAndSlug(
@PathVariable String username,
@ -55,11 +65,20 @@ public class RecipeController {
includeRawText,
viewer
);
final Map<String, Object> body = new HashMap<>();
body.put("recipe", view);
body.put("isStarred", this.recipeService.isStarer(username, slug, viewer));
body.put("isOwner", this.recipeService.isOwner(username, slug, viewer));
return ResponseEntity.ok(body);
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, viewer));
}
@PostMapping("/{username}/{slug}")
public ResponseEntity<Map<String, Object>> updateByUsernameAndSlug(
@PathVariable String username,
@PathVariable String slug,
@RequestParam(defaultValue = "false") boolean includeRawText,
@RequestBody RecipeUpdateSpec updateSpec,
@AuthenticationPrincipal User principal
) throws ImageException, RecipeException {
final Recipe updated = this.recipeService.update(username, slug, updateSpec, principal);
final FullRecipeView view = this.recipeService.toFullRecipeView(updated, includeRawText, principal);
return ResponseEntity.ok(this.getFullViewWrapper(username, slug, view, principal));
}
@GetMapping

View File

@ -6,6 +6,7 @@ import org.jetbrains.annotations.Nullable;
public interface RecipeSecurity {
boolean isOwner(Recipe recipe, User user);
boolean isOwner(long recipeId, User user) throws RecipeException;
boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException;
boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException;
boolean isViewableBy(String ownerUsername, String slug, @Nullable User user) throws RecipeException;
boolean isViewableBy(long recipeId, @Nullable User user) throws RecipeException;

View File

@ -29,6 +29,17 @@ public class RecipeSecurityImpl implements RecipeSecurity {
return this.isOwner(recipe, user);
}
@Override
public boolean isOwner(String username, String slug, @Nullable User user) throws RecipeException {
final Recipe recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(
() -> new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + username + " and slug " + slug
)
);
return this.isOwner(recipe, user);
}
@Override
public boolean isViewableBy(Recipe recipe, @Nullable User user) throws RecipeException {
if (recipe.isPublic()) {

View File

@ -1,5 +1,6 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.recipe.spec.RecipeCreateSpec;
import app.mealsmadeeasy.api.recipe.spec.RecipeUpdateSpec;
import app.mealsmadeeasy.api.recipe.view.FullRecipeView;
@ -34,7 +35,8 @@ public interface RecipeService {
List<Recipe> getRecipesViewableBy(User viewer);
List<Recipe> getRecipesOwnedBy(User owner);
Recipe update(long id, RecipeUpdateSpec spec, User modifier) throws RecipeException;
Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
throws RecipeException, ImageException;
Recipe addViewer(long id, User modifier, User viewer) throws RecipeException;
Recipe removeViewer(long id, User modifier, User viewer) throws RecipeException;
@ -42,6 +44,9 @@ public interface RecipeService {
void deleteRecipe(long id, User modifier);
FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer);
RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer);
@Contract("_, _, null -> null")
@Nullable Boolean isStarer(String username, String slug, @Nullable User viewer);

View File

@ -1,6 +1,7 @@
package app.mealsmadeeasy.api.recipe;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.image.ImageException;
import app.mealsmadeeasy.api.image.ImageService;
import app.mealsmadeeasy.api.image.S3ImageEntity;
import app.mealsmadeeasy.api.image.view.ImageView;
@ -199,46 +200,37 @@ public class RecipeServiceImpl implements RecipeService {
}
@Override
@PreAuthorize("@recipeSecurity.isOwner(#id, #modifier)")
public Recipe update(long id, RecipeUpdateSpec spec, User modifier) throws RecipeException {
final RecipeEntity entity = this.findRecipeEntity(id);
boolean didModify = false;
if (spec.getSlug() != null) {
entity.setSlug(spec.getSlug());
didModify = true;
@PreAuthorize("@recipeSecurity.isOwner(#username, #slug, #modifier)")
public Recipe update(String username, String slug, RecipeUpdateSpec spec, User modifier)
throws RecipeException, ImageException {
final RecipeEntity recipe = this.recipeRepository.findByOwnerUsernameAndSlug(username, slug).orElseThrow(() ->
new RecipeException(
RecipeException.Type.INVALID_USERNAME_OR_SLUG,
"No such Recipe for username " + username + " and slug: " + slug
)
);
recipe.setTitle(spec.getTitle());
recipe.setPreparationTime(spec.getPreparationTime());
recipe.setCookingTime(spec.getCookingTime());
recipe.setTotalTime(spec.getTotalTime());
recipe.setRawText(spec.getRawText());
recipe.setPublic(spec.getIsPublic());
final S3ImageEntity mainImage;
if (spec.getMainImageUpdateSpec() == null) {
mainImage = null;
} else {
mainImage = (S3ImageEntity) this.imageService.getByUsernameAndFilename(
spec.getMainImageUpdateSpec().getUsername(),
spec.getMainImageUpdateSpec().getFilename(),
modifier
);
}
if (spec.getTitle() != null) {
entity.setTitle(spec.getTitle());
didModify = true;
}
if (spec.getPreparationTime() != null) {
entity.setPreparationTime(spec.getPreparationTime());
didModify = true;
}
if (spec.getCookingTime() != null) {
entity.setCookingTime(spec.getCookingTime());
didModify = true;
}
if (spec.getTotalTime() != null) {
entity.setTotalTime(spec.getTotalTime());
didModify = true;
}
if (spec.getRawText() != null) {
entity.setRawText(spec.getRawText());
didModify = true;
}
if (spec.getPublic() != null) {
entity.setPublic(spec.getPublic());
didModify = true;
}
if (spec.getMainImage() != null) {
entity.setMainImage((S3ImageEntity) spec.getMainImage());
didModify = true;
}
if (didModify) {
entity.setModified(LocalDateTime.now());
}
return this.recipeRepository.save(entity);
recipe.setMainImage(mainImage);
recipe.setModified(LocalDateTime.now());
return this.recipeRepository.save(recipe);
}
@Override
@ -277,6 +269,16 @@ public class RecipeServiceImpl implements RecipeService {
this.recipeRepository.deleteById(id);
}
@Override
public FullRecipeView toFullRecipeView(Recipe recipe, boolean includeRawText, @Nullable User viewer) {
return this.getFullView((RecipeEntity) recipe, includeRawText, viewer);
}
@Override
public RecipeInfoView toRecipeInfoView(Recipe recipe, @Nullable User viewer) {
return this.getInfoView((RecipeEntity) recipe, viewer);
}
@Override
@PreAuthorize("@recipeSecurity.isViewableBy(#username, #slug, #viewer)")
@Contract("_, _, null -> null")

View File

@ -1,25 +1,65 @@
package app.mealsmadeeasy.api.recipe.spec;
import app.mealsmadeeasy.api.image.Image;
import app.mealsmadeeasy.api.recipe.Recipe;
import org.jetbrains.annotations.Nullable;
// For now, we cannot change slug after creation.
// In the future, we may be able to have redirects from
// old slugs to new slugs.
public class RecipeUpdateSpec {
private @Nullable String slug;
private @Nullable String title;
public static class MainImageUpdateSpec {
private String username;
private String filename;
public String getUsername() {
return this.username;
}
public void setUsername(String username) {
this.username = username;
}
public String getFilename() {
return this.filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
}
private String title;
private @Nullable Integer preparationTime;
private @Nullable Integer cookingTime;
private @Nullable Integer totalTime;
private @Nullable String rawText;
private @Nullable Boolean isPublic;
private @Nullable Image mainImage;
private String rawText;
private boolean isPublic;
private @Nullable MainImageUpdateSpec mainImageUpdateSpec;
public @Nullable String getSlug() {
return this.slug;
}
public RecipeUpdateSpec() {}
public void setSlug(@Nullable String slug) {
this.slug = slug;
/**
* Convenience constructor for testing purposes.
*
* @param recipe the Recipe to copy from
*/
public RecipeUpdateSpec(Recipe recipe) {
this.title = recipe.getTitle();
this.preparationTime = recipe.getPreparationTime();
this.cookingTime = recipe.getCookingTime();
this.totalTime = recipe.getTotalTime();
this.rawText = recipe.getRawText();
this.isPublic = recipe.isPublic();
final @Nullable Image mainImage = recipe.getMainImage();
if (mainImage != null) {
this.mainImageUpdateSpec = new MainImageUpdateSpec();
this.mainImageUpdateSpec.setUsername(mainImage.getOwner().getUsername());
this.mainImageUpdateSpec.setFilename(mainImage.getUserFilename());
}
}
public @Nullable String getTitle() {
@ -62,20 +102,20 @@ public class RecipeUpdateSpec {
this.rawText = rawText;
}
public @Nullable Boolean getPublic() {
public boolean getIsPublic() {
return this.isPublic;
}
public void setPublic(@Nullable Boolean isPublic) {
public void setIsPublic(boolean isPublic) {
this.isPublic = isPublic;
}
public @Nullable Image getMainImage() {
return this.mainImage;
public @Nullable MainImageUpdateSpec getMainImageUpdateSpec() {
return this.mainImageUpdateSpec;
}
public void setMainImage(@Nullable Image mainImage) {
this.mainImage = mainImage;
public void setMainImageUpdateSpec(@Nullable MainImageUpdateSpec mainImageUpdateSpec) {
this.mainImageUpdateSpec = mainImageUpdateSpec;
}
}