diff --git a/build.gradle b/build.gradle index 4b26794..900cb84 100644 --- a/build.gradle +++ b/build.gradle @@ -83,10 +83,10 @@ dependencies { runtimeOnly 'org.apache.xmlgraphics:batik-all:1.19' // Custom testing - testRuntimeOnly 'com.h2database:h2' testImplementation 'org.testcontainers:testcontainers:1.21.4' testImplementation 'org.testcontainers:junit-jupiter:1.21.4' - testImplementation "org.testcontainers:minio:1.21.4" + testImplementation 'org.testcontainers:postgresql:1.21.4' + testImplementation 'org.testcontainers:minio:1.21.4' testFixturesImplementation 'org.hamcrest:hamcrest:3.0' } diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/IntegrationTestsExtension.java b/src/integrationTest/java/app/mealsmadeeasy/api/IntegrationTestsExtension.java new file mode 100644 index 0000000..c8751c0 --- /dev/null +++ b/src/integrationTest/java/app/mealsmadeeasy/api/IntegrationTestsExtension.java @@ -0,0 +1,19 @@ +package app.mealsmadeeasy.api; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.containers.PostgreSQLContainer; + +public class IntegrationTestsExtension implements BeforeAllCallback { + + @Override + public void beforeAll(ExtensionContext context) { + final PostgreSQLContainer postgres = new PostgreSQLContainer<>("pgvector/pgvector:pg18-trixie") + .withDatabaseName("meals_made_easy_api"); + postgres.start(); + System.setProperty("spring.datasource.url", postgres.getJdbcUrl()); + System.setProperty("spring.datasource.username", postgres.getUsername()); + System.setProperty("spring.datasource.password", postgres.getPassword()); + } + +} diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/MealsMadeEasyApiApplicationTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/MealsMadeEasyApiApplicationTests.java index 6bd1f7f..399ff19 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/MealsMadeEasyApiApplicationTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/MealsMadeEasyApiApplicationTests.java @@ -1,12 +1,14 @@ package app.mealsmadeeasy.api; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest -class MealsMadeEasyApiApplicationTests { +@ExtendWith(IntegrationTestsExtension.class) +public class MealsMadeEasyApiApplicationTests { @Test - void contextLoads() {} + public void contextLoads() {} } diff --git a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java index 42f4e7e..11f359b 100644 --- a/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java +++ b/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java @@ -1,5 +1,6 @@ package app.mealsmadeeasy.api.image; +import app.mealsmadeeasy.api.IntegrationTestsExtension; import app.mealsmadeeasy.api.auth.AuthService; import app.mealsmadeeasy.api.auth.LoginException; import app.mealsmadeeasy.api.image.body.ImageUpdateInfoBody; @@ -9,12 +10,12 @@ 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.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; import org.springframework.mock.web.MockMultipartFile; -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; @@ -27,7 +28,7 @@ import tools.jackson.databind.ObjectMapper; import java.io.IOException; import java.io.InputStream; import java.util.Set; -import java.util.stream.Collectors; +import java.util.UUID; import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.Matchers.empty; @@ -38,10 +39,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @Testcontainers @SpringBootTest @AutoConfigureMockMvc +@ExtendWith(IntegrationTestsExtension.class) public class ImageControllerTests { - private static final String USER_FILENAME = "HAL9000.svg"; - @Container private static final MinIOContainer container = new MinIOContainer( DockerImageName.parse("minio/minio:latest") @@ -54,7 +54,7 @@ public class ImageControllerTests { registry.add("app.mealsmadeeasy.api.minio.secretKey", container::getPassword); } - private static InputStream getHal9000() { + private static InputStream getHal9000InputStream() { return ImageControllerTests.class.getResourceAsStream("HAL9000.svg"); } @@ -73,65 +73,73 @@ public class ImageControllerTests { @Autowired private ObjectMapper objectMapper; - private User createTestUser(String username) { + private static final String TEST_PASSWORD = "test"; + private static final long HAL_SIZE = 27881L; + + private static String makeUserFilename() { + return UUID.randomUUID() + ".svg"; + } + + private User seedUser() { + final String uuid = UUID.randomUUID().toString(); try { - return this.userService.createUser(username, username + "@test.com", "test"); + return this.userService.createUser(uuid, uuid + "@test.com", TEST_PASSWORD); } catch (UserCreateException e) { throw new RuntimeException(e); } } - private Image createHal9000(User owner) throws ImageException, IOException { - try (final InputStream hal9000 = getHal9000()) { + private Image seedImage(User owner, ImageCreateInfoSpec spec) { + try { return this.imageService.create( owner, - USER_FILENAME, - hal9000, - 27881L, - new ImageCreateInfoSpec() + makeUserFilename(), + getHal9000InputStream(), + HAL_SIZE, + spec ); + } catch (IOException | ImageException e) { + throw new RuntimeException(e); } } - private String getAccessToken(String username) { + private Image seedImage(User owner) { + return this.seedImage(owner, new ImageCreateInfoSpec()); + } + + private static String getImageUrl(User owner, Image image) { + return "/images/" + owner.getUsername() + "/" + image.getUserFilename(); + } + + private String getAccessToken(User user) { try { - return this.authService.login(username, "test").getAccessToken().getToken(); + return this.authService.login(user.getUsername(), TEST_PASSWORD).getAccessToken().getToken(); } catch (LoginException e) { throw new RuntimeException(e); } } - private Image makePublic(Image image, User modifier) { - final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec(); - spec.setPublic(true); - return this.imageService.update(image, modifier, spec); - } - - private Image addViewer(Image image, User modifier, User viewerToAdd) { - final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec(); - spec.setViewersToAdd(Set.of(viewerToAdd)); - return this.imageService.update(image, modifier, spec); - } - @Test - @DirtiesContext - public void getImageNoPrincipal() throws Exception { - final User owner = this.createTestUser("imageOwner"); - final Image image = this.createHal9000(owner); - this.makePublic(image, owner); - try (final InputStream hal9000 = getHal9000()) { + public void getPublicImageNoPrincipal() throws Exception { + final User owner = this.seedUser(); + final ImageCreateInfoSpec spec = new ImageCreateInfoSpec(); + spec.setPublic(true); + final Image image = this.seedImage(owner, spec); + + // Assert bytes the same and proper mime type + try (final InputStream hal9000 = getHal9000InputStream()) { final byte[] halBytes = hal9000.readAllBytes(); - this.mockMvc.perform(get("/images/imageOwner/HAL9000.svg")) + this.mockMvc.perform(get(getImageUrl(owner, image))) .andExpect(status().isOk()) .andExpect(content().contentType("image/svg+xml")) .andExpect(content().bytes(halBytes)); } } - private void doGetImageTestWithViewer(String accessToken) throws Exception { - try (final InputStream hal9000 = getHal9000()) { + private void doGetImageTestWithAccessToken(User owner, Image image, String accessToken) throws Exception { + try (final InputStream hal9000 = getHal9000InputStream()) { final byte[] halBytes = hal9000.readAllBytes(); - this.mockMvc.perform(get("/images/imageOwner/HAL9000.svg") + this.mockMvc.perform(get(getImageUrl(owner, image)) .header("Authorization", "Bearer " + accessToken)) .andExpect(status().isOk()) .andExpect(content().contentType("image/svg+xml")) @@ -140,88 +148,73 @@ public class ImageControllerTests { } @Test - @DirtiesContext - public void getImageWithOwner() throws Exception { - final User owner = this.createTestUser("imageOwner"); - this.createHal9000(owner); - final String accessToken = this.getAccessToken(owner.getUsername()); - this.doGetImageTestWithViewer(accessToken); + public void getImageForOwner() throws Exception { + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(owner)); } @Test - @DirtiesContext - public void getImageWithViewer() throws Exception { - final User owner = this.createTestUser("imageOwner"); - final User viewer = this.createTestUser("viewer"); - final Image image = this.createHal9000(owner); - this.addViewer(image, owner, viewer); - final String accessToken = this.getAccessToken(viewer.getUsername()); - this.doGetImageTestWithViewer(accessToken); + public void getImageForViewer() throws Exception { + final User owner = this.seedUser(); + final User viewer = this.seedUser(); + final Image image = this.seedImage(owner); + + // add viewer + final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec(); + spec.setViewersToAdd(Set.of(viewer)); + this.imageService.update(image, owner, spec); + + this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer)); } @Test - @DirtiesContext public void getNonPublicImageNoPrincipalForbidden() throws Exception { - final User owner = this.createTestUser("imageOwner"); - this.createHal9000(owner); - this.mockMvc.perform( - get("/images/imageOwner/HAL9000.svg") - ).andExpect(status().isForbidden()); + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + this.mockMvc.perform(get(getImageUrl(owner, image))) + .andExpect(status().isForbidden()); } @Test - @DirtiesContext public void getNonPublicImageWithPrincipalForbidden() throws Exception { - final User owner = this.createTestUser("imageOwner"); - final User viewer = this.createTestUser("viewer"); - this.createHal9000(owner); - final String accessToken = this.getAccessToken(viewer.getUsername()); + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + final User nonViewer = this.seedUser(); + final String nonViewerToken = this.getAccessToken(nonViewer); this.mockMvc.perform( - get("/images/imageOwner/HAL9000.svg") - .header("Authorization", "Bearer " + accessToken) + get(getImageUrl(owner, image)) + .header("Authorization", "Bearer " + nonViewerToken) ).andExpect(status().isForbidden()); } @Test - @DirtiesContext public void getImageWithViewersNoPrincipalForbidden() throws Exception { - final User owner = this.createTestUser("imageOwner"); - final User viewer = this.createTestUser("viewer"); - final Image image = this.createHal9000(owner); - this.addViewer(image, owner, viewer); - this.mockMvc.perform( - get("/images/imageOwner/HAL9000.svg") - ).andExpect(status().isForbidden()); + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + final User viewer = this.seedUser(); + + // add viewer + final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec(); + spec.setViewersToAdd(Set.of(viewer)); + this.imageService.update(image, owner, spec); + + this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden()); } @Test - @DirtiesContext - public void getImageWithViewersWrongViewerForbidden() throws Exception { - final User owner = this.createTestUser("imageOwner"); - final User viewer = this.createTestUser("viewer"); - final User wrongViewer = this.createTestUser("wrongViewer"); - final Image image = this.createHal9000(owner); - this.addViewer(image, owner, viewer); - final String accessToken = this.getAccessToken(wrongViewer.getUsername()); - this.mockMvc.perform( - get("/images/imageOwner/HAL9000.svg") - .header("Authorization", "Bearer " + accessToken) - ).andExpect(status().isForbidden()); - } - - @Test - @DirtiesContext public void putImage() throws Exception { - final User owner = this.createTestUser("imageOwner"); - final String accessToken = this.getAccessToken(owner.getUsername()); - try (final InputStream hal9000 = getHal9000()) { + final User owner = this.seedUser(); + final String accessToken = this.getAccessToken(owner); + try (final InputStream hal9000 = getHal9000InputStream()) { + final String userFilename = makeUserFilename(); final MockMultipartFile mockMultipartFile = new MockMultipartFile( - "image", "HAL9000.svg", "image/svg+xml", hal9000 + "image", userFilename, "image/svg+xml", hal9000 ); this.mockMvc.perform( multipart("/images") .file(mockMultipartFile) - .param("filename", "HAL9000.svg") + .param("filename", userFilename) .param("alt", "HAL 9000") .param("caption", "HAL 9000, from 2001: A Space Odyssey") .param("isPublic", "true") @@ -234,31 +227,26 @@ public class ImageControllerTests { .andExpect(status().isCreated()) .andExpect(jsonPath("$.created").exists()) .andExpect(jsonPath("$.modified").value(nullValue())) - .andExpect(jsonPath("$.filename").value(USER_FILENAME)) + .andExpect(jsonPath("$.filename").value(userFilename)) .andExpect(jsonPath("$.mimeType").value("image/svg+xml")) .andExpect(jsonPath("$.alt").value("HAL 9000")) .andExpect(jsonPath("$.caption").value("HAL 9000, from 2001: A Space Odyssey")) .andExpect(jsonPath("$.isPublic").value(true)) - .andExpect(jsonPath("$.owner.username").value("imageOwner")) + .andExpect(jsonPath("$.owner.username").value(owner.getUsername())) .andExpect(jsonPath("$.owner.id").value(owner.getId())) .andExpect(jsonPath("$.viewers").value(empty())); } } - private String prepUpdate() throws ImageException, IOException { - final User owner = this.createTestUser("imageOwner"); - this.createHal9000(owner); - return this.getAccessToken(owner.getUsername()); - } - @Test - @DirtiesContext public void updateAlt() throws Exception { - final String accessToken = this.prepUpdate(); + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + final String accessToken = this.getAccessToken(owner); final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); body.setAlt("HAL 9000"); this.mockMvc.perform( - post("/images/imageOwner/HAL9000.svg") + post(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -269,13 +257,14 @@ public class ImageControllerTests { } @Test - @DirtiesContext public void updateCaption() throws Exception { - final String accessToken = this.prepUpdate(); + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + final String accessToken = this.getAccessToken(owner); final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); body.setCaption("HAL 9000 from 2001: A Space Odyssey"); this.mockMvc.perform( - post("/images/imageOwner/HAL9000.svg") + post(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -286,13 +275,14 @@ public class ImageControllerTests { } @Test - @DirtiesContext public void updateIsPublic() throws Exception { - final String accessToken = this.prepUpdate(); + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + final String accessToken = this.getAccessToken(owner); final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); body.setPublic(true); this.mockMvc.perform( - post("/images/imageOwner/HAL9000.svg") + post(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -303,16 +293,18 @@ public class ImageControllerTests { } @Test - @DirtiesContext public void addViewers() throws Exception { - final String accessToken = this.prepUpdate(); + final User owner = this.seedUser(); + final User viewerToAdd = this.seedUser(); + final Image image = this.seedImage(owner); + final String accessToken = this.getAccessToken(owner); + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); - final Set viewerUsernames = Set.of(this.createTestUser("imageViewer")).stream() - .map(User::getUsername) - .collect(Collectors.toSet()); + final Set viewerUsernames = Set.of(viewerToAdd.getUsername()); body.setViewersToAdd(viewerUsernames); + this.mockMvc.perform( - post("/images/imageOwner/HAL9000.svg") + post(getImageUrl(owner, image)) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -320,15 +312,15 @@ public class ImageControllerTests { .andExpect(status().isOk()) .andExpect(jsonPath("$.modified").value(notNullValue())) .andExpect(jsonPath("$.viewers").value(not(empty()))) - .andExpect(jsonPath("$.viewers[0].username").value("imageViewer")); + .andExpect(jsonPath("$.viewers[0].username").value(viewerToAdd.getUsername())); } private record OwnerViewerImage(User owner, User viewer, Image image) {} - private OwnerViewerImage prepOwnerViewerImage() throws ImageException, IOException { - final User owner = this.createTestUser("imageOwner"); - final User viewer = this.createTestUser("imageViewer"); - final Image image = this.createHal9000(owner); + private OwnerViewerImage prepOwnerViewerImage() { + final User owner = this.seedUser(); + final User viewer = this.seedUser(); + final Image image = this.seedImage(owner); final ImageUpdateInfoSpec spec = new ImageUpdateInfoSpec(); spec.setViewersToAdd(Set.of(viewer)); this.imageService.update(image, owner, spec); @@ -336,14 +328,15 @@ public class ImageControllerTests { } @Test - @DirtiesContext public void removeViewers() throws Exception { final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); - final String accessToken = this.getAccessToken(ownerViewerImage.owner().getUsername()); + final String accessToken = this.getAccessToken(ownerViewerImage.owner()); + final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername())); + this.mockMvc.perform( - post("/images/imageOwner/HAL9000.svg") + post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -354,14 +347,13 @@ public class ImageControllerTests { } @Test - @DirtiesContext public void clearAllViewers() throws Exception { final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); - final String accessToken = this.getAccessToken(ownerViewerImage.owner().getUsername()); + final String accessToken = this.getAccessToken(ownerViewerImage.owner()); final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); body.setClearAllViewers(true); this.mockMvc.perform( - post("/images/imageOwner/HAL9000.svg") + post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) @@ -372,14 +364,13 @@ public class ImageControllerTests { } @Test - @DirtiesContext public void updateInfoByViewerForbidden() throws Exception { final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); - final String accessToken = this.getAccessToken(ownerViewerImage.viewer().getUsername()); // viewer + final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer final ImageUpdateInfoBody body = new ImageUpdateInfoBody(); this.mockMvc.perform( - post("/images/imageOwner/HAL9000.svg") - .contentType(MediaType.APPLICATION_JSON ) + post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) + .contentType(MediaType.APPLICATION_JSON) .content(this.objectMapper.writeValueAsString(body)) .header("Authorization", "Bearer " + accessToken) ) @@ -389,13 +380,12 @@ public class ImageControllerTests { } @Test - @DirtiesContext public void deleteImageWithOwner() throws Exception { - final User owner = this.createTestUser("imageOwner"); - final Image image = this.createHal9000(owner); - final String accessToken = this.getAccessToken(owner.getUsername()); + final User owner = this.seedUser(); + final Image image = this.seedImage(owner); + final String accessToken = this.getAccessToken(owner); this.mockMvc.perform( - delete("/images/imageOwner/HAL9000.svg") + delete(getImageUrl(owner, image)) .header("Authorization", "Bearer " + accessToken) ) .andExpect(status().isNoContent()); @@ -403,12 +393,11 @@ public class ImageControllerTests { } @Test - @DirtiesContext public void deleteImageByViewerForbidden() throws Exception { final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage(); - final String accessToken = this.getAccessToken(ownerViewerImage.viewer().getUsername()); + final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); this.mockMvc.perform( - delete("/images/imageOwner/HAL9000.svg") + delete(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image())) .header("Authorization", "Bearer " + accessToken) ) .andExpect(status().isForbidden()); diff --git a/src/integrationTest/resources/application.properties b/src/integrationTest/resources/application.properties index 60d3fb7..e26398a 100644 --- a/src/integrationTest/resources/application.properties +++ b/src/integrationTest/resources/application.properties @@ -1,7 +1,3 @@ -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1 -spring.datasource.username=sa -spring.datasource.password=sa app.mealsmadeeasy.api.baseUrl=http://localhost:8080 app.mealsmadeeasy.api.security.access-token-lifetime=60 app.mealsmadeeasy.api.security.refresh-token-lifetime=120 diff --git a/src/main/resources/db/migration/V2__create_image_owner_id_user_filename_index.sql b/src/main/resources/db/migration/V2__create_image_owner_id_user_filename_index.sql new file mode 100644 index 0000000..0992161 --- /dev/null +++ b/src/main/resources/db/migration/V2__create_image_owner_id_user_filename_index.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX image_owner_id_user_filename ON image (owner_id, user_filename); \ No newline at end of file