package app.mealsmadeeasy.api.recipe; import app.mealsmadeeasy.api.auth.AuthService; import app.mealsmadeeasy.api.auth.LoginDetails; import app.mealsmadeeasy.api.auth.LoginException; import app.mealsmadeeasy.api.image.Image; import app.mealsmadeeasy.api.image.ImageService; import app.mealsmadeeasy.api.image.S3ImageServiceTests; import app.mealsmadeeasy.api.image.spec.ImageCreateInfoSpec; 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.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; import org.testcontainers.containers.MinIOContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; import org.testcontainers.utility.DockerImageName; import java.io.InputStream; import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.nullValue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Testcontainers @SpringBootTest @AutoConfigureMockMvc public class RecipeControllerTests { @Container private static final MinIOContainer container = new MinIOContainer( DockerImageName.parse("minio/minio:latest") ); @DynamicPropertySource public static void minioProperties(DynamicPropertyRegistry registry) { registry.add("app.mealsmadeeasy.api.minio.endpoint", container::getS3URL); registry.add("app.mealsmadeeasy.api.minio.accessKey", container::getUserName); registry.add("app.mealsmadeeasy.api.minio.secretKey", container::getPassword); } private static InputStream getHal9000() { return S3ImageServiceTests.class.getClassLoader().getResourceAsStream("HAL9000.svg"); } @Autowired private MockMvc mockMvc; @Autowired private RecipeService recipeService; @Autowired private RecipeStarService recipeStarService; @Autowired private UserService userService; @Autowired private AuthService authService; @Autowired private ImageService imageService; @Autowired private ObjectMapper objectMapper; private User createTestUser(String username) { try { return this.userService.createUser(username, username + "@test.com", "test"); } catch (UserCreateException e) { throw new RuntimeException(e); } } private Recipe createTestRecipe(User owner, boolean isPublic, String slug) { final RecipeCreateSpec spec = new RecipeCreateSpec(); spec.setSlug(slug); spec.setTitle("Test Recipe"); spec.setPreparationTime(10); spec.setCookingTime(20); spec.setTotalTime(30); spec.setRawText("# Hello, World!"); spec.setPublic(isPublic); return this.recipeService.create(owner, spec); } private Recipe createTestRecipe(User owner, boolean isPublic) { return this.createTestRecipe(owner, isPublic, "test-recipe"); } private String getAccessToken(User user) throws LoginException { return this.authService.login(user.getUsername(), "test") .getAccessToken() .getToken(); } private Image createHal9000(User owner) { try (final InputStream hal9000 = getHal9000()) { return this.imageService.create( owner, "HAL9000.svg", hal9000, 27881L, new ImageCreateInfoSpec() ); } catch (Exception e) { throw new RuntimeException(e); } } @Test @DirtiesContext public void getRecipePageViewByIdPublicRecipeNoPrincipal() throws Exception { final User owner = this.createTestUser("owner"); final Recipe recipe = this.createTestRecipe(owner, true); this.mockMvc.perform( get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.recipe.id").value(1)) .andExpect(jsonPath("$.recipe.created").exists()) // TODO: better matching of exact LocalDateTime .andExpect(jsonPath("$.recipe.modified").doesNotExist()) .andExpect(jsonPath("$.recipe.slug").value(recipe.getSlug())) .andExpect(jsonPath("$.recipe.title").value("Test Recipe")) .andExpect(jsonPath("$.recipe.preparationTime").value(recipe.getPreparationTime())) .andExpect(jsonPath("$.recipe.cookingTime").value(recipe.getCookingTime())) .andExpect(jsonPath("$.recipe.totalTime").value(recipe.getTotalTime())) .andExpect(jsonPath("$.recipe.text").value("

Hello, World!

")) .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("$.recipe.mainImage").value(nullValue())) .andExpect(jsonPath("$.isStarred").value(nullValue())) .andExpect(jsonPath("$.isOwner").value(nullValue())); } @Test @DirtiesContext public void getFullRecipeViewIncludeRawText() throws Exception { final User owner = this.createTestUser("owner"); final Recipe recipe = this.createTestRecipe(owner, true); this.mockMvc.perform( get( "/recipes/{username}/{slug}?includeRawText=true", recipe.getOwner().getUsername(), recipe.getSlug() ) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.recipe.rawText").value(recipe.getRawText())); } @Test @DirtiesContext public void getFullRecipeViewPrincipalIsStarer() throws Exception { final User owner = this.createTestUser("owner"); final Recipe recipe = this.createTestRecipe(owner, false); this.recipeStarService.create(recipe.getId(), owner.getUsername()); final String accessToken = this.getAccessToken(owner); this.mockMvc.perform( get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()) .header("Authorization", "Bearer " + accessToken) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.isStarred").value(true)); } @Test @DirtiesContext public void getFullRecipeViewPrincipalIsNotStarer() throws Exception { final User owner = this.createTestUser("owner"); final Recipe recipe = this.createTestRecipe(owner, false); final String accessToken = this.getAccessToken(owner); this.mockMvc.perform( get("/recipes/{username}/{slug}", recipe.getOwner().getUsername(), recipe.getSlug()) .header("Authorization", "Bearer " + accessToken) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.isStarred").value(false)); } @Test @DirtiesContext public void getRecipeInfoViewsNoPrincipal() throws Exception { final User owner = this.createTestUser("owner"); final Recipe recipe = this.createTestRecipe(owner, true); this.mockMvc.perform(get("/recipes")) .andExpect(status().isOk()) .andExpect(jsonPath("$.slice.number").value(0)) .andExpect(jsonPath("$.slice.size").value(20)) .andExpect(jsonPath("$.content").isArray()) .andExpect(jsonPath("$.content", hasSize(1))) .andExpect(jsonPath("$.content[0].id").value(recipe.getId())) .andExpect(jsonPath("$.content[0].created").exists()) // TODO: better matching of exact LocalDateTime .andExpect(jsonPath("$.content[0].modified").doesNotExist()) .andExpect(jsonPath("$.content[0].slug").value(recipe.getSlug())) .andExpect(jsonPath("$.content[0].title").value(recipe.getTitle())) .andExpect(jsonPath("$.content[0].preparationTime").value(recipe.getPreparationTime())) .andExpect(jsonPath("$.content[0].cookingTime").value(recipe.getCookingTime())) .andExpect(jsonPath("$.content[0].totalTime").value(recipe.getTotalTime())) .andExpect(jsonPath("$.content[0].owner.id").value(owner.getId())) .andExpect(jsonPath("$.content[0].owner.username").value(owner.getUsername())) .andExpect(jsonPath("$.content[0].isPublic").value(true)) .andExpect(jsonPath("$.content[0].starCount").value(0)) .andExpect(jsonPath("$.content[0].mainImage").value(nullValue())); } @Test @DirtiesContext public void getRecipeInfoViewsWithPrincipalIncludesPrivate() throws Exception { final User owner = this.createTestUser("owner"); final Recipe r0 = this.createTestRecipe(owner, true, "r0"); final Recipe r1 = this.createTestRecipe(owner, true, "r1"); final Recipe r2 = this.createTestRecipe(owner, false, "r2"); final LoginDetails loginDetails = this.authService.login(owner.getUsername(), "test"); this.mockMvc.perform( get("/recipes") .header("Authorization", "Bearer " + loginDetails.getAccessToken().getToken()) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.slice.number").value(0)) .andExpect(jsonPath("$.slice.size").value(20)) .andExpect(jsonPath("$.content").isArray()) .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("

Hello, Updated World!

")) .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("$.recipe.mainImage").value(nullValue())) .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 updateRecipeReturnsViewWithMainImage() throws Exception { final User owner = this.createTestUser("owner"); final Image hal9000 = this.createHal9000(owner); final RecipeCreateSpec createSpec = new RecipeCreateSpec(); createSpec.setTitle("Test Recipe"); createSpec.setSlug("test-recipe"); createSpec.setPublic(false); createSpec.setRawText("# Hello, World!"); createSpec.setMainImage(hal9000); Recipe recipe = this.recipeService.create(owner, createSpec); final RecipeUpdateSpec updateSpec = new RecipeUpdateSpec(); updateSpec.setTitle("Updated Test Recipe"); updateSpec.setRawText("# Hello, Updated World!"); final RecipeUpdateSpec.MainImageUpdateSpec mainImageUpdateSpec = new RecipeUpdateSpec.MainImageUpdateSpec(); mainImageUpdateSpec.setUsername(hal9000.getOwner().getUsername()); mainImageUpdateSpec.setFilename(hal9000.getUserFilename()); updateSpec.setMainImageUpdateSpec(mainImageUpdateSpec); final String body = this.objectMapper.writeValueAsString(updateSpec); final String accessToken = this.getAccessToken(owner); 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.mainImage").isMap()); } @Test @DirtiesContext public void addStarToRecipe() throws Exception { final User owner = this.createTestUser("recipe-owner"); final User starer = this.createTestUser("recipe-starer"); final Recipe recipe = this.createTestRecipe(owner, true); this.mockMvc.perform( post("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug()) .header("Authorization", "Bearer " + this.getAccessToken(starer)) ) .andExpect(status().isCreated()) .andExpect(jsonPath("$.date").exists()); } @Test @DirtiesContext public void getStarForRecipe() throws Exception { final User owner = this.createTestUser("recipe-owner"); final User starer = this.createTestUser("recipe-starer"); final Recipe recipe = this.createTestRecipe(owner, true); this.recipeStarService.create(recipe.getId(), starer.getUsername()); this.mockMvc.perform( get("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug()) .header("Authorization", "Bearer " + this.getAccessToken(starer)) ) .andExpect(status().isOk()) .andExpect(jsonPath("$.isStarred").value(true)) .andExpect(jsonPath("$.star").isMap()) .andExpect(jsonPath("$.star.date").exists()); } @Test @DirtiesContext public void deleteStarFromRecipe() throws Exception { final User owner = this.createTestUser("recipe-owner"); final User starer = this.createTestUser("recipe-starer"); final Recipe recipe = this.createTestRecipe(owner, true); this.recipeStarService.create(recipe.getId(), starer.getUsername()); this.mockMvc.perform( delete("/recipes/{username}/{slug}/star", recipe.getOwner().getUsername(), recipe.getSlug()) .header("Authorization", "Bearer " + this.getAccessToken(starer)) ) .andExpect(status().isNoContent()); } }