From db9e9eca07056af8ccd8044b25af94148fcd4d9a Mon Sep 17 00:00:00 2001 From: Jesse Brault Date: Mon, 19 Jan 2026 13:55:16 -0600 Subject: [PATCH] Add some tests, more to do. --- ...ecipeDraftsControllerIntegrationTests.java | 117 ++++++++++++++++++ .../api/recipe/RecipeDraftRepository.java | 10 +- .../api/recipe/RecipeDraftsController.java | 39 +++--- .../api/recipe/RecipeService.java | 16 ++- .../api/util/ExceptionHandlers.java | 35 ++++++ .../api/util/MustBeLoggedInException.java | 13 ++ 6 files changed, 211 insertions(+), 19 deletions(-) create mode 100644 src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java create mode 100644 src/main/java/app/mealsmadeeasy/api/util/ExceptionHandlers.java create mode 100644 src/main/java/app/mealsmadeeasy/api/util/MustBeLoggedInException.java diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java new file mode 100644 index 0000000..bef9350 --- /dev/null +++ b/src/integrationTest/java/app/mealsmadeeasy/api/recipe/RecipeDraftsControllerIntegrationTests.java @@ -0,0 +1,117 @@ +package app.mealsmadeeasy.api.recipe; + +import app.mealsmadeeasy.api.IntegrationTestsExtension; +import app.mealsmadeeasy.api.auth.AuthService; +import app.mealsmadeeasy.api.auth.LoginException; +import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.user.UserCreateException; +import app.mealsmadeeasy.api.user.UserService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +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.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ExtendWith(IntegrationTestsExtension.class) +@AutoConfigureMockMvc +public class RecipeDraftsControllerIntegrationTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserService userService; + + @Autowired + private AuthService authService; + + @Autowired + private RecipeService recipeService; + + private static final String TEST_PASSWORD = "test"; + + private User seedUser() { + final String uuid = UUID.randomUUID().toString(); + try { + return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD); + } catch (UserCreateException e) { + throw new RuntimeException(e); + } + } + + private String getAccessToken(User user) throws LoginException { + return this.authService.login(user.getUsername(), TEST_PASSWORD) + .getAccessToken() + .getToken(); + } + + @Test + public void whenNoDraft_getReturnsNotFound() throws Exception { + final User user = this.seedUser(); + final String fakeId = UUID.randomUUID().toString(); + this.mockMvc.perform( + get("/recipe-drafts/{fakeId}", fakeId) + .header("Authorization", "Bearer " + this.getAccessToken(user)) + ) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.entityName", is("RecipeDraft"))) + .andExpect(jsonPath("$.id", is(fakeId))) + .andExpect(jsonPath("$.message", is(notNullValue()))); + } + + @Test + public void whenNoUser_getReturnsUnauthorized() throws Exception { + this.mockMvc.perform( + get("/recipe-drafts/{fakeId}", UUID.randomUUID().toString()) + ) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message", is(notNullValue()))); + } + + @Test + public void whenDraftExists_returnsDraftById() throws Exception { + final User owner = this.seedUser(); + final RecipeDraft seeded = this.recipeService.createDraft(owner); + this.mockMvc.perform( + get("/recipe-drafts/{id}", seeded.getId()) + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(seeded.getId().toString()))) + .andExpect(jsonPath("$.created", is(notNullValue()))) + .andExpect(jsonPath("$.modified", is(nullValue()))) + .andExpect(jsonPath("$.state", is(RecipeDraft.State.ENTER_DATA.toString()))) + .andExpect(jsonPath("$.title", is(nullValue()))) + .andExpect(jsonPath("$.preparationTime", is(nullValue()))) + .andExpect(jsonPath("$.cookingTime", is(nullValue()))) + .andExpect(jsonPath("$.ingredients", is(nullValue()))) + .andExpect(jsonPath("$.rawText", is(nullValue()))) + .andExpect(jsonPath("$.owner.username", is(owner.getUsername()))) + .andExpect(jsonPath("$.mainImage", is(nullValue()))); + } + + @Test + public void whenDraftsExist_returnDrafts() throws Exception { + final User owner = this.seedUser(); + final RecipeDraft seed0 = this.recipeService.createDraft(owner); + final RecipeDraft seed1 = this.recipeService.createDraft(owner); + this.mockMvc.perform( + get("/recipe-drafts") + .header("Authorization", "Bearer " + this.getAccessToken(owner)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$", hasSize(2))); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftRepository.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftRepository.java index c3d6b41..4944614 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftRepository.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftRepository.java @@ -1,7 +1,15 @@ package app.mealsmadeeasy.api.recipe; +import app.mealsmadeeasy.api.user.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.util.List; import java.util.UUID; -public interface RecipeDraftRepository extends JpaRepository {} +public interface RecipeDraftRepository extends JpaRepository { + + @Query("SELECT r FROM RecipeDraft r WHERE :user = r.owner") + List findAllViewableBy(User user); + +} diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java index 210d707..01b7838 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeDraftsController.java @@ -11,6 +11,7 @@ import app.mealsmadeeasy.api.recipe.spec.RecipeDraftUpdateSpec; import app.mealsmadeeasy.api.recipe.view.FullRecipeView; import app.mealsmadeeasy.api.recipe.view.RecipeDraftView; import app.mealsmadeeasy.api.user.User; +import app.mealsmadeeasy.api.util.MustBeLoggedInException; import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.Nullable; import org.springframework.http.HttpStatus; @@ -20,7 +21,7 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.util.Optional; +import java.util.List; import java.util.UUID; @RestController @@ -39,28 +40,35 @@ public class RecipeDraftsController { : null; } + @GetMapping + public ResponseEntity> getAllDraftsForUser(@AuthenticationPrincipal User user) { + if (user == null) { + throw new MustBeLoggedInException(); + } + final List recipeDrafts = this.recipeService.getDrafts(user); + return ResponseEntity.ok(recipeDrafts.stream().map(recipeDraft -> { + final @Nullable ImageView mainImage = this.getImageView(recipeDraft, user); + return RecipeDraftView.from(recipeDraft, mainImage); + }).toList()); + } + @GetMapping("/{id}") public ResponseEntity getRecipeDraft( @PathVariable UUID id, @AuthenticationPrincipal User viewer ) { if (viewer == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); - } - final Optional maybeRecipeDraft = this.recipeService.findDraftById(id, viewer); - if (maybeRecipeDraft.isEmpty()) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } else { - final RecipeDraft recipeDraft = maybeRecipeDraft.get(); - final @Nullable ImageView imageView = this.getImageView(recipeDraft, viewer); - return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView)); + throw new MustBeLoggedInException(); } + final RecipeDraft recipeDraft = this.recipeService.getDraftByIdWithViewer(id, viewer); + final @Nullable ImageView imageView = this.getImageView(recipeDraft, viewer); + return ResponseEntity.ok(RecipeDraftView.from(recipeDraft, imageView)); } @PostMapping("/manual") public ResponseEntity createManualDraft(@AuthenticationPrincipal User owner) { if (owner == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + throw new MustBeLoggedInException(); } final RecipeDraft recipeDraft = this.recipeService.createDraft(owner); final ImageView imageView = this.getImageView(recipeDraft, owner); @@ -74,7 +82,7 @@ public class RecipeDraftsController { @RequestParam String sourceFileName ) throws IOException { if (owner == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + throw new MustBeLoggedInException(); } final File file = this.fileService.create( sourceFile.getInputStream(), @@ -94,7 +102,7 @@ public class RecipeDraftsController { @RequestBody RecipeDraftUpdateBody updateBody ) throws ImageException { if (modifier == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + throw new MustBeLoggedInException(); } final RecipeDraftUpdateSpec spec = this.updateBodyToSpecConverter.convert(updateBody, modifier); final RecipeDraft updated = this.recipeService.updateDraft(id, spec, modifier); @@ -108,7 +116,7 @@ public class RecipeDraftsController { @PathVariable UUID id ) { if (modifier == null) { - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + throw new MustBeLoggedInException(); } this.recipeService.deleteDraft(id, modifier); return ResponseEntity.noContent().build(); @@ -119,6 +127,9 @@ public class RecipeDraftsController { @AuthenticationPrincipal User modifier, @PathVariable UUID id ) { + if (modifier == null) { + throw new MustBeLoggedInException(); + } final Recipe recipe = this.recipeService.publishDraft(id, modifier); final FullRecipeView view = this.recipeService.toFullRecipeView(recipe, false, modifier); return ResponseEntity.status(HttpStatus.CREATED).body(view); diff --git a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java index 65b4a9b..b410802 100644 --- a/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java +++ b/src/main/java/app/mealsmadeeasy/api/recipe/RecipeService.java @@ -31,7 +31,10 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import java.time.OffsetDateTime; -import java.util.*; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; @Service public class RecipeService { @@ -325,6 +328,10 @@ public class RecipeService { return viewer.getUsername().equals(username); } + public List getDrafts(User viewer) { + return this.recipeDraftRepository.findAllViewableBy(viewer); + } + public RecipeDraft createDraft(User owner) { final var recipeDraft = new RecipeDraft(); recipeDraft.setState(RecipeDraft.State.ENTER_DATA); @@ -358,9 +365,10 @@ public class RecipeService { )); } - @PostAuthorize("@recipeDraftSecurity.isViewableBy(#returnObject, #viewer)") - public Optional findDraftById(UUID id, @Nullable User viewer) { - return this.recipeDraftRepository.findById(id); + @PostAuthorize("@recipeDraftSecurity.isViewableBy(returnObject, #viewer)") + public RecipeDraft getDraftByIdWithViewer(UUID id, @Nullable User viewer) { + return this.recipeDraftRepository.findById(id) + .orElseThrow(() -> new NoSuchEntityWithIdException(RecipeDraft.class, id)); } public RecipeDraft saveDraft(RecipeDraft recipeDraft) { diff --git a/src/main/java/app/mealsmadeeasy/api/util/ExceptionHandlers.java b/src/main/java/app/mealsmadeeasy/api/util/ExceptionHandlers.java new file mode 100644 index 0000000..5b70046 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/util/ExceptionHandlers.java @@ -0,0 +1,35 @@ +package app.mealsmadeeasy.api.util; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class ExceptionHandlers { + + public record NoSuchEntityWithIdExceptionView(String entityName, T id, String message) {} + + @ExceptionHandler(NoSuchEntityWithIdException.class) + public ResponseEntity> handleNoSuchEntityWithIdException( + NoSuchEntityWithIdException e + ) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new NoSuchEntityWithIdExceptionView<>( + e.getEntityType().getSimpleName(), + e.getId(), + "Could not find " + e.getEntityType().getSimpleName() + " with id " + e.getId() + )); + } + + public record MustBeLoggedInExceptionView(String message) {} + + @ExceptionHandler(MustBeLoggedInException.class) + public ResponseEntity handleMustBeLoggedInException( + MustBeLoggedInException e + ) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new MustBeLoggedInExceptionView(e.getMessage())); + } + +} diff --git a/src/main/java/app/mealsmadeeasy/api/util/MustBeLoggedInException.java b/src/main/java/app/mealsmadeeasy/api/util/MustBeLoggedInException.java new file mode 100644 index 0000000..c5ec7f8 --- /dev/null +++ b/src/main/java/app/mealsmadeeasy/api/util/MustBeLoggedInException.java @@ -0,0 +1,13 @@ +package app.mealsmadeeasy.api.util; + +public class MustBeLoggedInException extends RuntimeException { + + public MustBeLoggedInException() { + super("Must be logged in to perform the requested operation."); + } + + public MustBeLoggedInException(String message) { + super(message); + } + +}