Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,12 @@ public class AccommodationPhotoController {

@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RequireRole("HOST")
public ResponseEntity<AccommodationPhotoResponse> uploadPhoto(
public ResponseEntity<List<AccommodationPhotoResponse>> uploadPhotos(
@PathVariable UUID accommodationId,
@RequestPart("file") MultipartFile file,
@RequestParam(value = "displayOrder", required = false) Integer displayOrder,
@RequestPart("files") List<MultipartFile> files,
UserContext userContext) {
AccommodationPhotoResponse response = photoService.uploadPhoto(accommodationId, file, displayOrder, userContext);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
List<AccommodationPhotoResponse> responses = photoService.uploadPhotos(accommodationId, files, userContext);
return ResponseEntity.status(HttpStatus.CREATED).body(responses);
}

@GetMapping
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.springframework.web.multipart.MultipartFile;

import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
Expand All @@ -38,6 +39,38 @@ public class AccommodationPhotoService {
@Value("${app.photo.allowed-content-types}")
private String allowedContentTypesConfig;

@Transactional
public List<AccommodationPhotoResponse> uploadPhotos(UUID accommodationId, List<MultipartFile> files, UserContext userContext) {
Accommodation accommodation = findAccommodationOrThrow(accommodationId);
validateOwnership(accommodation, userContext);

long currentCount = photoRepository.countByAccommodationId(accommodationId);
if (currentCount + files.size() > maxPhotosPerAccommodation) {
throw new PhotoLimitExceededException("Adding " + files.size() + " photos would exceed the limit of " + maxPhotosPerAccommodation + " photos per accommodation");
}

int nextOrder = photoRepository.findMaxDisplayOrder(accommodationId) + 1;
List<AccommodationPhoto> photos = new ArrayList<>();

for (MultipartFile file : files) {
validateContentType(file.getContentType());
String storageFilename = photoStorageService.store(file);

AccommodationPhoto photo = AccommodationPhoto.builder()
.accommodationId(accommodationId)
.storageFilename(storageFilename)
.originalFilename(file.getOriginalFilename())
.contentType(file.getContentType())
.fileSize(file.getSize())
.displayOrder(nextOrder++)
.build();
photos.add(photoRepository.saveAndFlush(photo));
}

log.info("Uploaded {} photos for accommodation {}", photos.size(), accommodationId);
return photoMapper.toResponseList(photos);
}

@Transactional
public AccommodationPhotoResponse uploadPhoto(UUID accommodationId, MultipartFile file, Integer displayOrder, UserContext userContext) {
Accommodation accommodation = findAccommodationOrThrow(accommodationId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,44 +82,25 @@ class UploadPhotoEndpoint {
@DisplayName("With valid request returns 201")
void upload_WithValidRequest_Returns201() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "test content".getBytes());
"files", "test.jpg", "image/jpeg", "test content".getBytes());

when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class)))
.thenReturn(createPhotoResponse());
when(photoService.uploadPhotos(eq(ACCOMMODATION_ID), any(), any(UserContext.class)))
.thenReturn(List.of(createPhotoResponse()));

mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)
.file(file)
.header("X-User-Id", HOST_ID.toString())
.header("X-User-Role", "HOST"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(PHOTO_ID.toString()))
.andExpect(jsonPath("$.originalFilename").value("test.jpg"));
}

@Test
@DisplayName("With display order parameter uses it")
void upload_WithDisplayOrder_UsesParameter() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "test content".getBytes());

when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), eq(5), any(UserContext.class)))
.thenReturn(createPhotoResponse());

mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)
.file(file)
.param("displayOrder", "5")
.header("X-User-Id", HOST_ID.toString())
.header("X-User-Role", "HOST"))
.andExpect(status().isCreated());

verify(photoService).uploadPhoto(eq(ACCOMMODATION_ID), any(), eq(5), any(UserContext.class));
.andExpect(jsonPath("$[0].id").value(PHOTO_ID.toString()))
.andExpect(jsonPath("$[0].originalFilename").value("test.jpg"));
}

@Test
@DisplayName("With missing auth headers returns 401")
void upload_WithMissingAuthHeaders_Returns401() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "test content".getBytes());
"files", "test.jpg", "image/jpeg", "test content".getBytes());

mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)
.file(file))
Expand All @@ -130,7 +111,7 @@ void upload_WithMissingAuthHeaders_Returns401() throws Exception {
@DisplayName("With GUEST role returns 403")
void upload_WithGuestRole_Returns403() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "test content".getBytes());
"files", "test.jpg", "image/jpeg", "test content".getBytes());

mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)
.file(file)
Expand All @@ -143,9 +124,9 @@ void upload_WithGuestRole_Returns403() throws Exception {
@DisplayName("With accommodation not found returns 404")
void upload_WithAccommodationNotFound_Returns404() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "test content".getBytes());
"files", "test.jpg", "image/jpeg", "test content".getBytes());

when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class)))
when(photoService.uploadPhotos(eq(ACCOMMODATION_ID), any(), any(UserContext.class)))
.thenThrow(new AccommodationNotFoundException("Not found"));

mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)
Expand All @@ -159,9 +140,9 @@ void upload_WithAccommodationNotFound_Returns404() throws Exception {
@DisplayName("With invalid content type returns 400")
void upload_WithInvalidContentType_Returns400() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.gif", "image/gif", "test content".getBytes());
"files", "test.gif", "image/gif", "test content".getBytes());

when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class)))
when(photoService.uploadPhotos(eq(ACCOMMODATION_ID), any(), any(UserContext.class)))
.thenThrow(new InvalidContentTypeException("Invalid content type"));

mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)
Expand All @@ -175,9 +156,9 @@ void upload_WithInvalidContentType_Returns400() throws Exception {
@DisplayName("With photo limit exceeded returns 400")
void upload_WithPhotoLimitExceeded_Returns400() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "test content".getBytes());
"files", "test.jpg", "image/jpeg", "test content".getBytes());

when(photoService.uploadPhoto(eq(ACCOMMODATION_ID), any(), any(), any(UserContext.class)))
when(photoService.uploadPhotos(eq(ACCOMMODATION_ID), any(), any(UserContext.class)))
.thenThrow(new PhotoLimitExceededException("Limit exceeded"));

mockMvc.perform(multipart("/api/accommodation/{accommodationId}/photos", ACCOMMODATION_ID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,48 +103,46 @@ void setup_CreateAccommodation() throws Exception {
@DisplayName("Upload photo with valid request returns 201")
void uploadPhoto_WithValidRequest_Returns201() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test-image.jpg", "image/jpeg",
"files", "test-image.jpg", "image/jpeg",
"fake image content".getBytes());

MvcResult result = mockMvc.perform(multipart(photosPath())
.file(file)
.header("X-User-Id", HOST_ID.toString())
.header("X-User-Role", "HOST"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").isNotEmpty())
.andExpect(jsonPath("$.accommodationId").value(accommodationId))
.andExpect(jsonPath("$.originalFilename").value("test-image.jpg"))
.andExpect(jsonPath("$.contentType").value("image/jpeg"))
.andExpect(jsonPath("$.displayOrder").value(0))
.andExpect(jsonPath("$[0].id").isNotEmpty())
.andExpect(jsonPath("$[0].accommodationId").value(accommodationId))
.andExpect(jsonPath("$[0].originalFilename").value("test-image.jpg"))
.andExpect(jsonPath("$[0].contentType").value("image/jpeg"))
.andReturn();

photoId = objectMapper.readTree(result.getResponse().getContentAsString())
.get("id").asText();
.get(0).get("id").asText();
}

@Test
@Order(3)
@DisplayName("Upload photo with custom display order uses provided order")
void uploadPhoto_WithDisplayOrder_UsesProvidedOrder() throws Exception {
@DisplayName("Upload second photo returns 201 with auto-assigned display order")
void uploadPhoto_SecondPhoto_Returns201() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "second-image.png", "image/png",
"files", "second-image.png", "image/png",
"fake image content 2".getBytes());

mockMvc.perform(multipart(photosPath())
.file(file)
.param("displayOrder", "5")
.header("X-User-Id", HOST_ID.toString())
.header("X-User-Role", "HOST"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.displayOrder").value(5));
.andExpect(jsonPath("$[0].originalFilename").value("second-image.png"));
}

@Test
@Order(4)
@DisplayName("Upload photo without auth headers returns 401")
void uploadPhoto_WithoutAuthHeaders_Returns401() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "content".getBytes());
"files", "test.jpg", "image/jpeg", "content".getBytes());

mockMvc.perform(multipart(photosPath())
.file(file))
Expand All @@ -156,7 +154,7 @@ void uploadPhoto_WithoutAuthHeaders_Returns401() throws Exception {
@DisplayName("Upload photo with GUEST role returns 403")
void uploadPhoto_WithGuestRole_Returns403() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "content".getBytes());
"files", "test.jpg", "image/jpeg", "content".getBytes());

mockMvc.perform(multipart(photosPath())
.file(file)
Expand All @@ -170,7 +168,7 @@ void uploadPhoto_WithGuestRole_Returns403() throws Exception {
@DisplayName("Upload photo with different host returns 403")
void uploadPhoto_WithDifferentHost_Returns403() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.jpg", "image/jpeg", "content".getBytes());
"files", "test.jpg", "image/jpeg", "content".getBytes());

mockMvc.perform(multipart(photosPath())
.file(file)
Expand All @@ -184,7 +182,7 @@ void uploadPhoto_WithDifferentHost_Returns403() throws Exception {
@DisplayName("Upload photo with invalid content type returns 400")
void uploadPhoto_WithInvalidContentType_Returns400() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "test.gif", "image/gif", "content".getBytes());
"files", "test.gif", "image/gif", "content".getBytes());

mockMvc.perform(multipart(photosPath())
.file(file)
Expand Down
7 changes: 7 additions & 0 deletions src/test/resources/application-test.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,12 @@ spring.application.name=accommodation-test
# Disable tracing in tests
management.tracing.enabled=false

# Use random port for gRPC server to avoid conflicts between test contexts
grpc.server.port=0

# gRPC client config for tests (won't actually connect unless used)
grpc.client.reservation-service.address=static://localhost:9090
grpc.client.reservation-service.negotiationType=plaintext

# Logging
logging.level.com.devoops=DEBUG