diff --git a/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CourtPhotoControllerFunctionalTest.java b/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CourtPhotoControllerFunctionalTest.java index 99a124a5a..dcbdaada1 100644 --- a/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CourtPhotoControllerFunctionalTest.java +++ b/src/functionalTest/java/uk/gov/hmcts/reform/fact/functional/controllers/CourtPhotoControllerFunctionalTest.java @@ -151,7 +151,7 @@ void shouldFailToUploadOversizedFile() { courtsWithPhotos.add(courtId); final File oversizedFile = new File( - "src/functionalTest/resources/test-images/test invalid jpg 3.2 MB.jpg"); + "src/functionalTest/resources/test-images/test invalid jpg 5.3 MB.jpg"); final Response uploadResponse = http.doMultipartPost( "/courts/" + courtId + "/v1/photo", "file", diff --git a/src/functionalTest/resources/test-images/test invalid jpg 3.2 MB.jpg b/src/functionalTest/resources/test-images/test invalid jpg 3.2 MB.jpg deleted file mode 100644 index 527fe774e..000000000 Binary files a/src/functionalTest/resources/test-images/test invalid jpg 3.2 MB.jpg and /dev/null differ diff --git a/src/functionalTest/resources/test-images/test invalid jpg 5.3 MB.jpg b/src/functionalTest/resources/test-images/test invalid jpg 5.3 MB.jpg new file mode 100644 index 000000000..ebbfa8e96 Binary files /dev/null and b/src/functionalTest/resources/test-images/test invalid jpg 5.3 MB.jpg differ diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/properties/PhotoConfigurationProperties.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/properties/PhotoConfigurationProperties.java new file mode 100644 index 000000000..bafc5407b --- /dev/null +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/config/properties/PhotoConfigurationProperties.java @@ -0,0 +1,18 @@ +package uk.gov.hmcts.reform.fact.data.api.config.properties; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "fact.data-api.photo", ignoreUnknownFields = false) +@Getter +@Setter +public class PhotoConfigurationProperties { + @Min(256) + @Max(2048) + private int maxWidth = 1024; +} diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/migration/service/PhotoMigrationService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/migration/service/PhotoMigrationService.java index 8af5a811b..ce46a0112 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/migration/service/PhotoMigrationService.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/migration/service/PhotoMigrationService.java @@ -9,7 +9,7 @@ import uk.gov.hmcts.reform.fact.data.api.migration.entities.MigrationStatus; import uk.gov.hmcts.reform.fact.data.api.migration.exception.MigrationAlreadyAppliedException; import uk.gov.hmcts.reform.fact.data.api.migration.model.CourtPhotoDto; -import uk.gov.hmcts.reform.fact.data.api.migration.model.InMemoryMultipartFile; +import uk.gov.hmcts.reform.fact.data.api.models.InMemoryMultipartFile; import uk.gov.hmcts.reform.fact.data.api.migration.model.LegacyExportResponse; import uk.gov.hmcts.reform.fact.data.api.migration.model.PhotoMigrationResponse; import uk.gov.hmcts.reform.fact.data.api.migration.repository.LegacyCourtMappingRepository; diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/migration/model/InMemoryMultipartFile.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/InMemoryMultipartFile.java similarity index 94% rename from src/main/java/uk/gov/hmcts/reform/fact/data/api/migration/model/InMemoryMultipartFile.java rename to src/main/java/uk/gov/hmcts/reform/fact/data/api/models/InMemoryMultipartFile.java index 086eec908..15a88b492 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/migration/model/InMemoryMultipartFile.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/models/InMemoryMultipartFile.java @@ -1,4 +1,4 @@ -package uk.gov.hmcts.reform.fact.data.api.migration.model; +package uk.gov.hmcts.reform.fact.data.api.models; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java index 39ac4cfd4..081ad009e 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoService.java @@ -1,11 +1,20 @@ package uk.gov.hmcts.reform.fact.data.api.services; import uk.gov.hmcts.reform.fact.data.api.audit.AuditUserContext; +import uk.gov.hmcts.reform.fact.data.api.config.properties.PhotoConfigurationProperties; import uk.gov.hmcts.reform.fact.data.api.entities.CourtPhoto; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; +import uk.gov.hmcts.reform.fact.data.api.models.InMemoryMultipartFile; import uk.gov.hmcts.reform.fact.data.api.repositories.CourtPhotoRepository; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Locale; import java.util.UUID; +import javax.imageio.ImageIO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,10 +26,15 @@ @RequiredArgsConstructor public class CourtPhotoService { + private static final String PNG = "png"; + private static final String JPG = "jpg"; + private static final String JPEG = "jpeg"; + private final CourtPhotoRepository courtPhotoRepository; private final CourtService courtService; private final AzureBlobService azureBlobService; private final AuditUserContext auditUserContext; + private final PhotoConfigurationProperties photoConfigurationProperties; /** * Get a court photo by court id. @@ -46,11 +60,13 @@ public CourtPhoto getCourtPhotoByCourtId(UUID courtId) { public CourtPhoto setCourtPhoto(UUID courtId, MultipartFile file) { courtService.getCourtById(courtId); + MultipartFile resizedFile = resizeIfNeeded(file); + CourtPhoto courtPhoto = courtPhotoRepository.findCourtPhotoByCourtId(courtId) .orElse(new CourtPhoto()); courtPhoto.setCourtId(courtId); - courtPhoto.setFileLink(azureBlobService.uploadFile(courtId.toString(), file)); + courtPhoto.setFileLink(azureBlobService.uploadFile(courtId.toString(), resizedFile)); courtPhoto.setUpdatedByUserId(auditUserContext.requireUserId()); return courtPhotoRepository.save(courtPhoto); @@ -71,4 +87,71 @@ public void deleteCourtPhotoByCourtId(UUID courtId) { azureBlobService.deleteBlob(courtPhoto.getCourtId().toString()); courtPhotoRepository.deleteById(courtPhoto.getId()); } + + private MultipartFile resizeIfNeeded(MultipartFile file) { + String format = detectImageFormat(file.getOriginalFilename(), file.getContentType()); + int maxWidth = photoConfigurationProperties.getMaxWidth(); + + try { + BufferedImage sourceImage = ImageIO.read(file.getInputStream()); + if (sourceImage == null) { + throw new IllegalArgumentException("Unable to read image content"); + } + + if (sourceImage.getWidth() <= maxWidth) { + return file; + } + + int newHeight = (int) Math.round((double) sourceImage.getHeight() * maxWidth / sourceImage.getWidth()); + + int imageType = PNG.equals(format) ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB; + BufferedImage resizedImage = new BufferedImage(maxWidth, newHeight, imageType); + + Graphics2D graphics = resizedImage.createGraphics(); + graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + graphics.drawImage(sourceImage, 0, 0, maxWidth, newHeight, null); + graphics.dispose(); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + if (!ImageIO.write(resizedImage, format, outputStream)) { + throw new IllegalStateException("No ImageIO writer found for format: " + format); + } + + return new InMemoryMultipartFile( + file.getName(), + file.getOriginalFilename(), + file.getContentType(), + outputStream.toByteArray() + ); + } catch (IOException ex) { + throw new IllegalArgumentException("Failed to process uploaded image", ex); + } + } + + private String detectImageFormat(String originalFilename, String contentType) { + if (contentType != null) { + String lower = contentType.toLowerCase(Locale.ROOT); + if (lower.contains(PNG)) { + return PNG; + } + if (lower.contains(JPEG) || lower.contains(JPG)) { + return JPG; + } + } + + if (originalFilename != null) { + String lower = originalFilename.toLowerCase(Locale.ROOT); + if (lower.endsWith("." + PNG)) { + return PNG; + } + if (lower.endsWith("." + JPG) || lower.endsWith("." + JPEG)) { + return JPG; + } + } + + // Defensive fallback as upstream validation should already ensure png/jpg. + return JPG; + } } diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/TestingSupportService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/TestingSupportService.java index 7ecc686ab..a88393a33 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/TestingSupportService.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/TestingSupportService.java @@ -35,7 +35,7 @@ import uk.gov.hmcts.reform.fact.data.api.entities.types.HearingEnhancementEquipment; import uk.gov.hmcts.reform.fact.data.api.entities.types.OpeningTimesDetail; import uk.gov.hmcts.reform.fact.data.api.entities.types.UserRole; -import uk.gov.hmcts.reform.fact.data.api.migration.model.InMemoryMultipartFile; +import uk.gov.hmcts.reform.fact.data.api.models.InMemoryMultipartFile; import uk.gov.hmcts.reform.fact.data.api.models.AreaOfLawSelectionDto; import uk.gov.hmcts.reform.fact.data.api.models.CourtLocalAuthorityDto; import uk.gov.hmcts.reform.fact.data.api.models.LocalAuthoritySelectionDto; diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/validation/validator/ImageValidator.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/validation/validator/ImageValidator.java index 12afd0771..b138bf5f7 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/validation/validator/ImageValidator.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/validation/validator/ImageValidator.java @@ -6,7 +6,12 @@ import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidFileException; import uk.gov.hmcts.reform.fact.data.api.validation.annotations.ValidImage; -import java.io.IOException; +import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; +import java.io.InputStream; +import java.util.Iterator; +import java.util.Locale; public class ImageValidator implements ConstraintValidator { @@ -17,41 +22,55 @@ public void initialize(ValidImage constraintAnnotation) { @Override public boolean isValid(MultipartFile file, ConstraintValidatorContext context) { - if (file == null || file.isEmpty()) { throw new InvalidFileException("File is empty"); } - try { - byte[] b = file.getBytes(); - if (!isPng(b) && !isJpeg(b)) { + try (InputStream inputStream = file.getInputStream(); + ImageInputStream imageInputStream = ImageIO.createImageInputStream(inputStream)) { + + if (imageInputStream == null) { + throw new InvalidFileException("Unreadable file"); + } + + Iterator readers = ImageIO.getImageReaders(imageInputStream); + if (!readers.hasNext()) { throw new InvalidFileException("Only JPEG or PNG files are allowed"); } - } catch (IOException e) { - throw new InvalidFileException("Unreadable file"); - } - return true; - } + ImageReader reader = readers.next(); + String format; + try { + reader.setInput(imageInputStream, true, true); + format = reader.getFormatName(); - private boolean isPng(byte[] b) { - return b.length >= 8 - && b[0] == (byte)0x89 - && b[1] == (byte)0x50 - && b[2] == (byte)0x4E - && b[3] == (byte)0x47 - && b[4] == (byte)0x0D - && b[5] == (byte)0x0A - && b[6] == (byte)0x1A - && b[7] == (byte)0x0A; + if (!isAllowedFormat(format)) { + throw new InvalidFileException("Only JPEG or PNG files are allowed"); + } + + // Force actual decode metadata/content read - truncated streams fail here. + int width = reader.getWidth(0); + int height = reader.getHeight(0); + if (width <= 0 || height <= 0) { + throw new InvalidFileException("Unreadable file"); + } + } finally { + reader.dispose(); + } + + return true; + } catch (InvalidFileException e) { + throw e; + } catch (Exception e) { + throw new InvalidFileException("Unreadable file"); + } } - private boolean isJpeg(byte[] b) { - return b.length >= 4 - && b[0] == (byte)0xFF - && b[1] == (byte)0xD8 - && b[b.length - 2] == (byte)0xFF - && b[b.length - 1] == (byte)0xD9; + private boolean isAllowedFormat(String format) { + if (format == null) { + return false; + } + String normalized = format.toLowerCase(Locale.ROOT); + return "jpeg".equals(normalized) || "jpg".equals(normalized) || "png".equals(normalized); } } - diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 5ecbd6713..d552035a2 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -80,7 +80,7 @@ spring: servlet: multipart: - max-file-size: 2MB + max-file-size: 4MB data: web: pageable: @@ -120,3 +120,5 @@ fact: data-api: audit: retention-days: ${AUDIT_RETENTION_DAYS:365} + photo: + max-width: ${PHOTO_MAX_WIDTH:1024} diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/migration/model/InMemoryMultipartFileTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/migration/model/InMemoryMultipartFileTest.java index 37a6a149e..198027a2d 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/migration/model/InMemoryMultipartFileTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/migration/model/InMemoryMultipartFileTest.java @@ -11,6 +11,8 @@ import static org.assertj.core.api.Assertions.assertThat; +import uk.gov.hmcts.reform.fact.data.api.models.InMemoryMultipartFile; + class InMemoryMultipartFileTest { @TempDir diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoServiceTest.java index 4fe7d5aab..62f277956 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoServiceTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/CourtPhotoServiceTest.java @@ -2,25 +2,43 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.multipart.MultipartFile; import uk.gov.hmcts.reform.fact.data.api.audit.AuditUserContext; +import uk.gov.hmcts.reform.fact.data.api.config.properties.PhotoConfigurationProperties; import uk.gov.hmcts.reform.fact.data.api.entities.CourtPhoto; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; import uk.gov.hmcts.reform.fact.data.api.repositories.CourtPhotoRepository; +import java.awt.image.BufferedImage; +import java.awt.image.RenderedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; import java.util.Optional; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import javax.imageio.ImageIO; +import javax.imageio.stream.ImageOutputStream; + @ExtendWith(MockitoExtension.class) class CourtPhotoServiceTest { @@ -41,6 +59,9 @@ class CourtPhotoServiceTest { @Mock private AuditUserContext auditUserContext; + @Mock + private PhotoConfigurationProperties photoConfigurationProperties; + @InjectMocks private CourtPhotoService courtPhotoService; @@ -76,15 +97,18 @@ void getCourtPhotoByCourtIdShouldThrowWhenPhotoNotFound() { } @Test - void setCourtPhotoShouldCreateNewWhenNoneExists() { + void setCourtPhotoShouldCreateNewWhenNoneExists() throws IOException { UUID courtId = UUID.randomUUID(); - String uploadedLink = "uploaded-file-link"; + final String uploadedLink = "uploaded-file-link"; when(courtService.getCourtById(courtId)).thenReturn(null); when(courtPhotoRepository.findCourtPhotoByCourtId(courtId)).thenReturn(Optional.empty()); - when(azureBlobService.uploadFile(eq(courtId.toString()), eq(multipartFile))).thenReturn(uploadedLink); + when(azureBlobService.uploadFile(eq(courtId.toString()), any(MultipartFile.class))).thenReturn(uploadedLink); when(auditUserContext.requireUserId()).thenReturn(USER_ID); when(courtPhotoRepository.save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); + when(multipartFile.getInputStream()) + .thenReturn(new ByteArrayInputStream(createImageBytes("jpg",1,1))); + when(photoConfigurationProperties.getMaxWidth()).thenReturn(640); CourtPhoto result = courtPhotoService.setCourtPhoto(courtId, multipartFile); @@ -95,7 +119,7 @@ void setCourtPhotoShouldCreateNewWhenNoneExists() { } @Test - void setCourtPhotoShouldUpdateExistingPhoto() { + void setCourtPhotoShouldUpdateExistingPhoto() throws IOException { UUID courtId = UUID.randomUUID(); CourtPhoto existing = new CourtPhoto(); existing.setCourtId(courtId); @@ -103,9 +127,12 @@ void setCourtPhotoShouldUpdateExistingPhoto() { when(courtService.getCourtById(courtId)).thenReturn(null); when(courtPhotoRepository.findCourtPhotoByCourtId(courtId)).thenReturn(Optional.of(existing)); - when(azureBlobService.uploadFile(eq(courtId.toString()), eq(multipartFile))).thenReturn("new-link"); + when(azureBlobService.uploadFile(eq(courtId.toString()), any(MultipartFile.class))).thenReturn("new-link"); when(auditUserContext.requireUserId()).thenReturn(USER_ID); when(courtPhotoRepository.save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); + when(multipartFile.getInputStream()) + .thenReturn(new ByteArrayInputStream(createImageBytes("jpg",1,1))); + when(photoConfigurationProperties.getMaxWidth()).thenReturn(640); CourtPhoto result = courtPhotoService.setCourtPhoto(courtId, multipartFile); @@ -115,6 +142,151 @@ void setCourtPhotoShouldUpdateExistingPhoto() { verify(courtPhotoRepository).save(result); } + @Test + void setCourtPhotoShouldResizeLargeImage() throws IOException { + UUID courtId = UUID.randomUUID(); + final String uploadedLink = "uploaded-file-link"; + + final byte[] imgBytes = createImageBytes("jpg",512,512); + + when(courtService.getCourtById(courtId)).thenReturn(null); + when(courtPhotoRepository.findCourtPhotoByCourtId(courtId)).thenReturn(Optional.empty()); + when(azureBlobService.uploadFile(eq(courtId.toString()), any(MultipartFile.class))).thenReturn(uploadedLink); + when(auditUserContext.requireUserId()).thenReturn(USER_ID); + when(courtPhotoRepository.save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); + when(multipartFile.getInputStream()).thenReturn(new ByteArrayInputStream(imgBytes)); + when(photoConfigurationProperties.getMaxWidth()).thenReturn(400); + + CourtPhoto result = courtPhotoService.setCourtPhoto(courtId, multipartFile); + + assertThat(result.getCourtId()).isEqualTo(courtId); + assertThat(result.getFileLink()).isEqualTo(uploadedLink); + assertThat(result.getUpdatedByUserId()).isEqualTo(USER_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(MultipartFile.class); + verify(azureBlobService).uploadFile(eq(courtId.toString()), captor.capture()); + assertThat(captor.getValue()).isNotNull(); + // Ensure the resized image is smaller than the original + assertThat(captor.getValue().getSize()).isLessThan(imgBytes.length); + // Ensure that other multipart methods are set + assertThat(captor.getValue().getBytes()).isNotEmpty(); + assertThat(captor.getValue().getInputStream()).isNotNull(); + assertThat(captor.getValue().isEmpty()).isFalse(); + verify(courtPhotoRepository).save(result); + } + + + @ParameterizedTest + @ValueSource(strings = {"test.png", "test.jpg"}) + void setCourtPhotoShouldDetectFileTypeFromOriginalFilename(String filename) throws IOException { + UUID courtId = UUID.randomUUID(); + final String uploadedLink = "uploaded-file-link"; + + when(courtService.getCourtById(courtId)).thenReturn(null); + when(courtPhotoRepository.findCourtPhotoByCourtId(courtId)).thenReturn(Optional.empty()); + when(azureBlobService.uploadFile(eq(courtId.toString()), any(MultipartFile.class))).thenReturn(uploadedLink); + when(auditUserContext.requireUserId()).thenReturn(USER_ID); + when(courtPhotoRepository.save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); + when(multipartFile.getInputStream()) + .thenReturn(new ByteArrayInputStream(createImageBytes("png",1,1))); + when(multipartFile.getOriginalFilename()).thenReturn(filename); + when(photoConfigurationProperties.getMaxWidth()).thenReturn(640); + + CourtPhoto result = courtPhotoService.setCourtPhoto(courtId, multipartFile); + + assertThat(result.getCourtId()).isEqualTo(courtId); + assertThat(result.getFileLink()).isEqualTo(uploadedLink); + assertThat(result.getUpdatedByUserId()).isEqualTo(USER_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(MultipartFile.class); + verify(azureBlobService).uploadFile(eq(courtId.toString()), captor.capture()); + assertThat(captor.getValue()).isNotNull(); + assertThat(captor.getValue().getOriginalFilename()).isEqualTo(filename); + verify(multipartFile, times(2)).getOriginalFilename(); + verify(courtPhotoRepository).save(result); + } + + @ParameterizedTest + @ValueSource(strings = {"image/png", "image/jpg", "image/jpeg"}) + void setCourtPhotoShouldDetectFileTypeFromContentType(String contentType) throws IOException { + UUID courtId = UUID.randomUUID(); + final String uploadedLink = "uploaded-file-link"; + + when(courtService.getCourtById(courtId)).thenReturn(null); + when(courtPhotoRepository.findCourtPhotoByCourtId(courtId)).thenReturn(Optional.empty()); + when(azureBlobService.uploadFile(eq(courtId.toString()), any(MultipartFile.class))).thenReturn(uploadedLink); + when(auditUserContext.requireUserId()).thenReturn(USER_ID); + when(courtPhotoRepository.save(any(CourtPhoto.class))).thenAnswer(inv -> inv.getArgument(0)); + when(multipartFile.getInputStream()) + .thenReturn(new ByteArrayInputStream(createImageBytes("png", 1, 1))); + when(multipartFile.getContentType()).thenReturn(contentType); + when(photoConfigurationProperties.getMaxWidth()).thenReturn(640); + + CourtPhoto result = courtPhotoService.setCourtPhoto(courtId, multipartFile); + + assertThat(result.getCourtId()).isEqualTo(courtId); + assertThat(result.getFileLink()).isEqualTo(uploadedLink); + assertThat(result.getUpdatedByUserId()).isEqualTo(USER_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(MultipartFile.class); + verify(azureBlobService).uploadFile(eq(courtId.toString()), captor.capture()); + assertThat(captor.getValue()).isNotNull(); + assertThat(captor.getValue().getContentType()).isEqualTo(contentType); + verify(multipartFile, times(2)).getContentType(); + verify(courtPhotoRepository).save(result); + } + + + @Test + void setCourtPhotoShouldThrowIllegalArgumentWhenImageIOFailsToReadFile() throws IOException { + UUID courtId = UUID.randomUUID(); + + when(courtService.getCourtById(courtId)).thenReturn(null); + when(multipartFile.getInputStream()).thenReturn(new ByteArrayInputStream("not-an-image".getBytes())); + when(photoConfigurationProperties.getMaxWidth()).thenReturn(640); + + try (MockedStatic imageIoMock = mockStatic(ImageIO.class)) { + imageIoMock.when(() -> ImageIO.read(any(InputStream.class))).thenReturn(null); + + assertThrows(IllegalArgumentException.class, () -> + courtPhotoService.setCourtPhoto(courtId, multipartFile) + ); + } + } + + @Test + void setCourtPhotoShouldThrowIllegalArgumentWhenImageIOFailsToWriteFile() throws IOException { + UUID courtId = UUID.randomUUID(); + + when(courtService.getCourtById(courtId)).thenReturn(null); + when(multipartFile.getInputStream()).thenReturn(new ByteArrayInputStream("not-an-image".getBytes())); + when(photoConfigurationProperties.getMaxWidth()).thenReturn(640); + + try (MockedStatic imageIoMock = mockStatic(ImageIO.class)) { + imageIoMock.when(() -> ImageIO.write( + any(RenderedImage.class), anyString(), any(ImageOutputStream.class) + )).thenReturn(false); + + assertThrows(IllegalArgumentException.class, () -> + courtPhotoService.setCourtPhoto(courtId, multipartFile) + ); + } + } + + @Test + void setCourtPhotoShouldThrowIllegalArgumentWhenIOExceptionOccurs() throws IOException { + UUID courtId = UUID.randomUUID(); + + when(courtService.getCourtById(courtId)).thenReturn(null); + when(multipartFile.getInputStream()).thenReturn(new ByteArrayInputStream("not-an-image".getBytes())); + when(photoConfigurationProperties.getMaxWidth()).thenReturn(640); + + try (MockedStatic imageIoMock = mockStatic(ImageIO.class)) { + imageIoMock.when(() -> ImageIO.read(any(InputStream.class))).thenThrow(new IOException()); + + assertThrows(IllegalArgumentException.class, () -> + courtPhotoService.setCourtPhoto(courtId, multipartFile) + ); + } + } + @Test void deleteCourtPhotoByCourtIdShouldDeleteSuccessfully() { UUID courtId = UUID.randomUUID(); @@ -146,4 +318,16 @@ void deleteCourtPhotoByCourtIdShouldThrowWhenPhotoNotFound() { assertThat(exception.getMessage()).isEqualTo("Court photo not found for court ID: " + courtId); } + + private byte[] createImageBytes(String format, int width, int height) throws IOException { + BufferedImage image = new BufferedImage(Math.max(1,width), Math.max(1,height), BufferedImage.TYPE_INT_RGB); + int[] pixels = new int[width * height]; + Arrays.fill(pixels, 0xFFFFFF); + image.setRGB(0,0,width,height,pixels,0,width); + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + ImageIO.write(image, format, outputStream); + return outputStream.toByteArray(); + } + } } diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/validator/ImageValidatorTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/validator/ImageValidatorTest.java index ea7ddfeed..b37dce874 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/validator/ImageValidatorTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/validator/ImageValidatorTest.java @@ -93,7 +93,6 @@ void shouldThrowWhenFileContentDoesNotMatchSignature() { void shouldThrowWhenFileCannotBeRead() throws IOException { MultipartFile unreadableFile = mock(MultipartFile.class); when(unreadableFile.isEmpty()).thenReturn(false); - when(unreadableFile.getBytes()).thenThrow(new IOException("Cannot read")); assertThrows(InvalidFileException.class, () -> imageValidator.isValid(unreadableFile, context)); }