meals-made-easy-api/src/integrationTest/java/app/mealsmadeeasy/api/image/ImageControllerTests.java
2026-01-15 16:18:02 -06:00

411 lines
17 KiB
Java

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.ImageUpdateBody;
import app.mealsmadeeasy.api.image.spec.ImageCreateSpec;
import app.mealsmadeeasy.api.image.spec.ImageUpdateSpec;
import app.mealsmadeeasy.api.user.User;
import app.mealsmadeeasy.api.user.UserCreateException;
import app.mealsmadeeasy.api.user.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
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.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
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.IOException;
import java.io.InputStream;
import java.util.Set;
import java.util.UUID;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.Matchers.empty;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Testcontainers
@SpringBootTest
@AutoConfigureMockMvc
@ExtendWith(IntegrationTestsExtension.class)
public class ImageControllerTests {
@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 getHal9000InputStream() {
return ImageControllerTests.class.getResourceAsStream("HAL9000.svg");
}
@Autowired
private UserService userService;
@Autowired
private AuthService authService;
@Autowired
private ImageService imageService;
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
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(uuid, uuid + "@test.com", TEST_PASSWORD);
} catch (UserCreateException e) {
throw new RuntimeException(e);
}
}
private Image seedImage(User owner, ImageCreateSpec spec) {
try {
return this.imageService.create(
owner,
makeUserFilename(),
getHal9000InputStream(),
HAL_SIZE,
spec
);
} catch (IOException | ImageException e) {
throw new RuntimeException(e);
}
}
private Image seedImage(User owner) {
return this.seedImage(owner, ImageCreateSpec.builder().build());
}
private static String getImageUrl(User owner, Image image) {
return "/images/" + owner.getUsername() + "/" + image.getUserFilename();
}
private String getAccessToken(User user) {
try {
return this.authService.login(user.getUsername(), TEST_PASSWORD).getAccessToken().getToken();
} catch (LoginException e) {
throw new RuntimeException(e);
}
}
@Test
public void getPublicImageNoPrincipal() throws Exception {
final User owner = this.seedUser();
final ImageCreateSpec spec = ImageCreateSpec.builder()
.isPublic(true)
.build();
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(getImageUrl(owner, image)))
.andExpect(status().isOk())
.andExpect(content().contentType("image/svg+xml"))
.andExpect(content().bytes(halBytes));
}
}
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(getImageUrl(owner, image))
.header("Authorization", "Bearer " + accessToken))
.andExpect(status().isOk())
.andExpect(content().contentType("image/svg+xml"))
.andExpect(content().bytes(halBytes));
}
}
@Test
public void getImageForOwner() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(owner));
}
@Test
public void getImageForViewer() throws Exception {
final User owner = this.seedUser();
final User viewer = this.seedUser();
final Image image = this.seedImage(owner);
// add viewer
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.viewersToAdd(Set.of(viewer))
.build();
this.imageService.update(image, owner, spec);
this.doGetImageTestWithAccessToken(owner, image, this.getAccessToken(viewer));
}
@Test
public void getNonPublicImageNoPrincipalForbidden() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
this.mockMvc.perform(get(getImageUrl(owner, image)))
.andExpect(status().isForbidden());
}
@Test
public void getNonPublicImageWithPrincipalForbidden() throws Exception {
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(getImageUrl(owner, image))
.header("Authorization", "Bearer " + nonViewerToken)
).andExpect(status().isForbidden());
}
@Test
public void getImageWithViewersNoPrincipalForbidden() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final User viewer = this.seedUser();
// add viewer
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.viewersToAdd(Set.of(viewer))
.build();
this.imageService.update(image, owner, spec);
this.mockMvc.perform(get(getImageUrl(owner, image))).andExpect(status().isForbidden());
}
@Test
public void putImage() throws Exception {
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", userFilename, "image/svg+xml", hal9000
);
this.mockMvc.perform(
multipart("/images")
.file(mockMultipartFile)
.param("filename", userFilename)
.param("alt", "HAL 9000")
.param("caption", "HAL 9000, from 2001: A Space Odyssey")
.param("isPublic", "true")
.header("Authorization", "Bearer " + accessToken)
.with(req -> {
req.setMethod("PUT");
return req;
})
)
.andExpect(status().isCreated())
.andExpect(jsonPath("$.created").exists())
.andExpect(jsonPath("$.modified").value(nullValue()))
.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("$.public").value(true))
.andExpect(jsonPath("$.owner.username").value(owner.getUsername()))
.andExpect(jsonPath("$.owner.id").value(owner.getId()))
.andExpect(jsonPath("$.viewers").value(empty()));
}
}
@Test
public void updateAlt() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateBody body = new ImageUpdateBody();
body.setAlt("HAL 9000");
this.mockMvc.perform(
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.modified").value(notNullValue()))
.andExpect(jsonPath("$.alt").value("HAL 9000"));
}
@Test
public void updateCaption() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateBody body = new ImageUpdateBody();
body.setCaption("HAL 9000 from 2001: A Space Odyssey");
this.mockMvc.perform(
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.modified").value(notNullValue()))
.andExpect(jsonPath("$.caption").value("HAL 9000 from 2001: A Space Odyssey"));
}
@Test
public void updateIsPublic() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateBody body = new ImageUpdateBody();
body.setIsPublic(true);
this.mockMvc.perform(
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.modified").value(notNullValue()))
.andExpect(jsonPath("$.public").value(true));
}
@Test
public void addViewers() throws Exception {
final User owner = this.seedUser();
final User viewerToAdd = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
final ImageUpdateBody body = new ImageUpdateBody();
final Set<String> viewerUsernames = Set.of(viewerToAdd.getUsername());
body.setViewersToAdd(viewerUsernames);
this.mockMvc.perform(
post(getImageUrl(owner, image))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.modified").value(notNullValue()))
.andExpect(jsonPath("$.viewers").value(not(empty())))
.andExpect(jsonPath("$.viewers[0].username").value(viewerToAdd.getUsername()));
}
private record OwnerViewerImage(User owner, User viewer, Image image) {}
private OwnerViewerImage prepOwnerViewerImage() {
final User owner = this.seedUser();
final User viewer = this.seedUser();
final Image image = this.seedImage(owner);
final ImageUpdateSpec spec = ImageUpdateSpec.builder()
.viewersToAdd(Set.of(viewer))
.build();
this.imageService.update(image, owner, spec);
return new OwnerViewerImage(owner, viewer, image);
}
@Test
public void removeViewers() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
final ImageUpdateBody body = new ImageUpdateBody();
body.setViewersToRemove(Set.of(ownerViewerImage.viewer().getUsername()));
this.mockMvc.perform(
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.modified").value(notNullValue()))
.andExpect(jsonPath("$.viewers").value(empty()));
}
@Test
public void clearAllViewers() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.owner());
final ImageUpdateBody body = new ImageUpdateBody();
body.setClearAllViewers(true);
this.mockMvc.perform(
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.modified").value(notNullValue()))
.andExpect(jsonPath("$.viewers").value(empty()));
}
@Test
public void updateInfoByViewerForbidden() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.viewer()); // viewer
final ImageUpdateBody body = new ImageUpdateBody();
this.mockMvc.perform(
post(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.contentType(MediaType.APPLICATION_JSON)
.content(this.objectMapper.writeValueAsString(body))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.statusCode").value(403))
.andExpect(jsonPath("$.message").value(notNullValue()));
}
@Test
public void deleteImageWithOwner() throws Exception {
final User owner = this.seedUser();
final Image image = this.seedImage(owner);
final String accessToken = this.getAccessToken(owner);
this.mockMvc.perform(
delete(getImageUrl(owner, image))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isNoContent());
assertThrows(ImageException.class, () -> this.imageService.getById(image.getId(), owner));
}
@Test
public void deleteImageByViewerForbidden() throws Exception {
final OwnerViewerImage ownerViewerImage = this.prepOwnerViewerImage();
final String accessToken = this.getAccessToken(ownerViewerImage.viewer());
this.mockMvc.perform(
delete(getImageUrl(ownerViewerImage.owner(), ownerViewerImage.image()))
.header("Authorization", "Bearer " + accessToken)
)
.andExpect(status().isForbidden());
}
}