From a99caee40917f80bc193f541d66bc38e0c6a2a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Tue, 17 Feb 2026 23:20:05 +0100 Subject: [PATCH 1/2] fix: Changed Accommodation endpoint to accept multiple photos instead of one --- .../AccommodationPhotoController.java | 9 +++-- .../service/AccommodationPhotoService.java | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java b/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java index 6902123..41580fd 100644 --- a/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java +++ b/src/main/java/com/devoops/accommodation/controller/AccommodationPhotoController.java @@ -26,13 +26,12 @@ public class AccommodationPhotoController { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @RequireRole("HOST") - public ResponseEntity uploadPhoto( + public ResponseEntity> uploadPhotos( @PathVariable UUID accommodationId, - @RequestPart("file") MultipartFile file, - @RequestParam(value = "displayOrder", required = false) Integer displayOrder, + @RequestPart("files") List files, UserContext userContext) { - AccommodationPhotoResponse response = photoService.uploadPhoto(accommodationId, file, displayOrder, userContext); - return ResponseEntity.status(HttpStatus.CREATED).body(response); + List responses = photoService.uploadPhotos(accommodationId, files, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(responses); } @GetMapping diff --git a/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java b/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java index 655ae5b..d03aff2 100644 --- a/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java +++ b/src/main/java/com/devoops/accommodation/service/AccommodationPhotoService.java @@ -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; @@ -38,6 +39,38 @@ public class AccommodationPhotoService { @Value("${app.photo.allowed-content-types}") private String allowedContentTypesConfig; + @Transactional + public List uploadPhotos(UUID accommodationId, List 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 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); From f66d99d7dd11144fb483f6f3bd36c18ce90050f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Tue, 17 Feb 2026 23:38:41 +0100 Subject: [PATCH 2/2] fix: Fixed related tests --- .../AccommodationPhotoControllerTest.java | 45 ++++++------------- .../AccommodationPhotoIntegrationTest.java | 30 ++++++------- .../resources/application-test.properties | 7 +++ 3 files changed, 34 insertions(+), 48 deletions(-) diff --git a/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java index 7829dd3..aeaf7fb 100644 --- a/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java +++ b/src/test/java/com/devoops/accommodation/controller/AccommodationPhotoControllerTest.java @@ -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)) @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java index c59a736..5da7175 100644 --- a/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java +++ b/src/test/java/com/devoops/accommodation/integration/AccommodationPhotoIntegrationTest.java @@ -103,7 +103,7 @@ 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()) @@ -111,32 +111,30 @@ void uploadPhoto_WithValidRequest_Returns201() throws Exception { .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 @@ -144,7 +142,7 @@ void uploadPhoto_WithDisplayOrder_UsesProvidedOrder() throws Exception { @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)) @@ -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) @@ -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) @@ -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) diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties index 9e787a6..069c3b7 100644 --- a/src/test/resources/application-test.properties +++ b/src/test/resources/application-test.properties @@ -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