feat(back-end): upload media
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
package com.printcalculator.service.admin;
|
||||
|
||||
import com.printcalculator.dto.AdminCreateMediaUsageRequest;
|
||||
import com.printcalculator.dto.AdminMediaAssetDto;
|
||||
import com.printcalculator.dto.AdminMediaUsageDto;
|
||||
import com.printcalculator.dto.AdminUpdateMediaAssetRequest;
|
||||
import com.printcalculator.entity.MediaAsset;
|
||||
import com.printcalculator.entity.MediaUsage;
|
||||
import com.printcalculator.entity.MediaVariant;
|
||||
import com.printcalculator.repository.MediaAssetRepository;
|
||||
import com.printcalculator.repository.MediaUsageRepository;
|
||||
import com.printcalculator.repository.MediaVariantRepository;
|
||||
import com.printcalculator.service.media.MediaFfmpegService;
|
||||
import com.printcalculator.service.media.MediaImageInspector;
|
||||
import com.printcalculator.service.media.MediaStorageService;
|
||||
import com.printcalculator.service.storage.ClamAVService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyCollection;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class AdminMediaControllerServiceTest {
|
||||
|
||||
@Mock
|
||||
private MediaAssetRepository mediaAssetRepository;
|
||||
@Mock
|
||||
private MediaVariantRepository mediaVariantRepository;
|
||||
@Mock
|
||||
private MediaUsageRepository mediaUsageRepository;
|
||||
@Mock
|
||||
private MediaImageInspector mediaImageInspector;
|
||||
@Mock
|
||||
private MediaFfmpegService mediaFfmpegService;
|
||||
@Mock
|
||||
private ClamAVService clamAVService;
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private AdminMediaControllerService service;
|
||||
private Path storageRoot;
|
||||
|
||||
private final Map<UUID, MediaAsset> assets = new LinkedHashMap<>();
|
||||
private final Map<UUID, MediaVariant> variants = new LinkedHashMap<>();
|
||||
private final Map<UUID, MediaUsage> usages = new LinkedHashMap<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
storageRoot = tempDir.resolve("storage_media");
|
||||
MediaStorageService mediaStorageService = new MediaStorageService(
|
||||
storageRoot.toString(),
|
||||
"https://cdn.example/media"
|
||||
);
|
||||
|
||||
service = new AdminMediaControllerService(
|
||||
mediaAssetRepository,
|
||||
mediaVariantRepository,
|
||||
mediaUsageRepository,
|
||||
mediaStorageService,
|
||||
mediaImageInspector,
|
||||
mediaFfmpegService,
|
||||
clamAVService,
|
||||
1024 * 1024
|
||||
);
|
||||
|
||||
when(clamAVService.scan(any())).thenReturn(true);
|
||||
|
||||
when(mediaAssetRepository.save(any(MediaAsset.class))).thenAnswer(invocation -> {
|
||||
MediaAsset asset = invocation.getArgument(0);
|
||||
if (asset.getId() == null) {
|
||||
asset.setId(UUID.randomUUID());
|
||||
}
|
||||
assets.put(asset.getId(), asset);
|
||||
return asset;
|
||||
});
|
||||
when(mediaAssetRepository.findById(any(UUID.class))).thenAnswer(invocation ->
|
||||
Optional.ofNullable(assets.get(invocation.getArgument(0)))
|
||||
);
|
||||
when(mediaAssetRepository.findAllByOrderByCreatedAtDesc()).thenAnswer(invocation -> assets.values().stream()
|
||||
.sorted(Comparator.comparing(MediaAsset::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)).reversed())
|
||||
.toList());
|
||||
|
||||
when(mediaVariantRepository.save(any(MediaVariant.class))).thenAnswer(invocation -> persistVariant(invocation.getArgument(0)));
|
||||
when(mediaVariantRepository.saveAll(any())).thenAnswer(invocation -> {
|
||||
Iterable<MediaVariant> iterable = invocation.getArgument(0);
|
||||
List<MediaVariant> saved = new ArrayList<>();
|
||||
for (MediaVariant variant : iterable) {
|
||||
saved.add(persistVariant(variant));
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
when(mediaVariantRepository.findByMediaAsset_IdIn(anyCollection())).thenAnswer(invocation ->
|
||||
variantsForAssets(invocation.getArgument(0))
|
||||
);
|
||||
when(mediaVariantRepository.findByMediaAsset_IdOrderByCreatedAtAsc(any(UUID.class))).thenAnswer(invocation ->
|
||||
variants.values().stream()
|
||||
.filter(variant -> variant.getMediaAsset().getId().equals(invocation.getArgument(0)))
|
||||
.sorted(Comparator.comparing(MediaVariant::getCreatedAt, Comparator.nullsLast(OffsetDateTime::compareTo)))
|
||||
.toList()
|
||||
);
|
||||
|
||||
when(mediaUsageRepository.save(any(MediaUsage.class))).thenAnswer(invocation -> persistUsage(invocation.getArgument(0)));
|
||||
when(mediaUsageRepository.saveAll(any())).thenAnswer(invocation -> {
|
||||
Iterable<MediaUsage> iterable = invocation.getArgument(0);
|
||||
List<MediaUsage> saved = new ArrayList<>();
|
||||
for (MediaUsage usage : iterable) {
|
||||
saved.add(persistUsage(usage));
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
when(mediaUsageRepository.findByMediaAsset_IdIn(anyCollection())).thenAnswer(invocation ->
|
||||
usagesForAssets(invocation.getArgument(0))
|
||||
);
|
||||
when(mediaUsageRepository.findByUsageScope(anyString(), anyString(), nullable(UUID.class))).thenAnswer(invocation ->
|
||||
usages.values().stream()
|
||||
.filter(usage -> usage.getUsageType().equals(invocation.getArgument(0)))
|
||||
.filter(usage -> usage.getUsageKey().equals(invocation.getArgument(1)))
|
||||
.filter(usage -> {
|
||||
UUID ownerId = invocation.getArgument(2);
|
||||
return ownerId == null ? usage.getOwnerId() == null : ownerId.equals(usage.getOwnerId());
|
||||
})
|
||||
.sorted(Comparator.comparing(MediaUsage::getSortOrder).thenComparing(MediaUsage::getCreatedAt))
|
||||
.toList()
|
||||
);
|
||||
when(mediaUsageRepository.findById(any(UUID.class))).thenAnswer(invocation ->
|
||||
Optional.ofNullable(usages.get(invocation.getArgument(0)))
|
||||
);
|
||||
|
||||
doAnswer(invocation -> {
|
||||
Path outputFile = invocation.getArgument(1);
|
||||
String format = invocation.getArgument(4);
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
Files.writeString(outputFile, "generated-" + format, StandardCharsets.UTF_8);
|
||||
return null;
|
||||
}).when(mediaFfmpegService).generateVariant(any(Path.class), any(Path.class), anyInt(), anyInt(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAsset_withValidImage_shouldPersistMetadataAndExposePublicUrls() throws Exception {
|
||||
when(mediaImageInspector.inspect(any(Path.class))).thenReturn(
|
||||
new MediaImageInspector.ImageMetadata("image/png", "png", 1600, 900)
|
||||
);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"landing-hero.png",
|
||||
"image/png",
|
||||
"png-image-content".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
AdminMediaAssetDto dto = service.uploadAsset(file, " Landing hero ", " Main headline ", null);
|
||||
|
||||
assertEquals("READY", dto.getStatus());
|
||||
assertEquals("PUBLIC", dto.getVisibility());
|
||||
assertEquals("landing-hero.png", dto.getOriginalFilename());
|
||||
assertEquals("Landing hero", dto.getTitle());
|
||||
assertEquals("Main headline", dto.getAltText());
|
||||
assertEquals("image/png", dto.getMimeType());
|
||||
assertEquals(1600, dto.getWidthPx());
|
||||
assertEquals(900, dto.getHeightPx());
|
||||
assertEquals(file.getSize(), dto.getFileSizeBytes());
|
||||
assertEquals(64, dto.getSha256Hex().length());
|
||||
assertEquals(10, dto.getVariants().size());
|
||||
|
||||
long publicVariants = dto.getVariants().stream()
|
||||
.filter(variant -> !"ORIGINAL".equals(variant.getFormat()))
|
||||
.count();
|
||||
assertEquals(9, publicVariants);
|
||||
assertTrue(dto.getVariants().stream()
|
||||
.filter(variant -> "WEBP".equals(variant.getFormat()) && "hero".equals(variant.getVariantName()))
|
||||
.allMatch(variant -> variant.getPublicUrl().startsWith("https://cdn.example/media/")));
|
||||
assertTrue(dto.getVariants().stream()
|
||||
.filter(variant -> "ORIGINAL".equals(variant.getFormat()))
|
||||
.allMatch(variant -> variant.getPublicUrl() == null));
|
||||
|
||||
MediaVariant heroWebp = variants.values().stream()
|
||||
.filter(variant -> "hero".equals(variant.getVariantName()))
|
||||
.filter(variant -> "WEBP".equals(variant.getFormat()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertTrue(Files.exists(storageRoot.resolve("public").resolve(heroWebp.getStorageKey())));
|
||||
|
||||
MediaVariant originalVariant = variants.values().stream()
|
||||
.filter(variant -> "ORIGINAL".equals(variant.getFormat()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertTrue(Files.exists(storageRoot.resolve("original").resolve(originalVariant.getStorageKey())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAsset_withUnsupportedImageType_shouldReturnBadRequest() throws Exception {
|
||||
when(mediaImageInspector.inspect(any(Path.class))).thenReturn(
|
||||
new MediaImageInspector.ImageMetadata("image/svg+xml", "svg", 400, 400)
|
||||
);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"logo.svg",
|
||||
"image/svg+xml",
|
||||
"<svg/>".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
ResponseStatusException ex = assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> service.uploadAsset(file, null, null, null)
|
||||
);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
assertTrue(assets.isEmpty());
|
||||
assertTrue(variants.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadAsset_withOversizedFile_shouldFailValidationBeforePersistence() {
|
||||
service = new AdminMediaControllerService(
|
||||
mediaAssetRepository,
|
||||
mediaVariantRepository,
|
||||
mediaUsageRepository,
|
||||
new MediaStorageService(storageRoot.toString(), "https://cdn.example/media"),
|
||||
mediaImageInspector,
|
||||
mediaFfmpegService,
|
||||
clamAVService,
|
||||
4
|
||||
);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"big.png",
|
||||
"image/png",
|
||||
"12345".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
ResponseStatusException ex = assertThrows(
|
||||
ResponseStatusException.class,
|
||||
() -> service.uploadAsset(file, null, null, null)
|
||||
);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, ex.getStatusCode());
|
||||
verifyNoInteractions(mediaAssetRepository, mediaVariantRepository, mediaUsageRepository, mediaImageInspector, mediaFfmpegService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createUsage_withPrimaryFlag_shouldUnsetExistingPrimaryAndMapUsageOnAsset() {
|
||||
MediaAsset asset = persistAsset(seedAsset("PUBLIC"));
|
||||
|
||||
MediaUsage existingPrimary = new MediaUsage();
|
||||
existingPrimary.setId(UUID.randomUUID());
|
||||
existingPrimary.setUsageType("HOME");
|
||||
existingPrimary.setUsageKey("landing");
|
||||
existingPrimary.setOwnerId(null);
|
||||
existingPrimary.setMediaAsset(asset);
|
||||
existingPrimary.setSortOrder(0);
|
||||
existingPrimary.setIsPrimary(true);
|
||||
existingPrimary.setIsActive(true);
|
||||
existingPrimary.setCreatedAt(OffsetDateTime.now().minusDays(1));
|
||||
persistUsage(existingPrimary);
|
||||
|
||||
AdminCreateMediaUsageRequest payload = new AdminCreateMediaUsageRequest();
|
||||
payload.setUsageType("home");
|
||||
payload.setUsageKey("landing");
|
||||
payload.setMediaAssetId(asset.getId());
|
||||
payload.setSortOrder(5);
|
||||
payload.setIsPrimary(true);
|
||||
|
||||
AdminMediaUsageDto created = service.createUsage(payload);
|
||||
|
||||
assertEquals("HOME", created.getUsageType());
|
||||
assertEquals("landing", created.getUsageKey());
|
||||
assertEquals(asset.getId(), created.getMediaAssetId());
|
||||
assertEquals(5, created.getSortOrder());
|
||||
assertTrue(created.getIsPrimary());
|
||||
assertFalse(usages.get(existingPrimary.getId()).getIsPrimary());
|
||||
|
||||
AdminMediaAssetDto assetDto = service.getAsset(asset.getId());
|
||||
assertEquals(2, assetDto.getUsages().size());
|
||||
assertTrue(assetDto.getUsages().stream().anyMatch(usage -> usage.getId().equals(created.getId()) && usage.getIsPrimary()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateAsset_withPrivateVisibility_shouldMoveGeneratedFilesAndHidePublicUrls() throws Exception {
|
||||
when(mediaImageInspector.inspect(any(Path.class))).thenReturn(
|
||||
new MediaImageInspector.ImageMetadata("image/jpeg", "jpg", 1200, 800)
|
||||
);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile(
|
||||
"file",
|
||||
"gallery.jpg",
|
||||
"image/jpeg",
|
||||
"jpeg-image-content".getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
|
||||
AdminMediaAssetDto uploaded = service.uploadAsset(file, null, null, "PUBLIC");
|
||||
MediaVariant thumbJpeg = variants.values().stream()
|
||||
.filter(variant -> "thumb".equals(variant.getVariantName()))
|
||||
.filter(variant -> "JPEG".equals(variant.getFormat()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
Path publicFile = storageRoot.resolve("public").resolve(thumbJpeg.getStorageKey());
|
||||
assertTrue(Files.exists(publicFile));
|
||||
|
||||
AdminUpdateMediaAssetRequest payload = new AdminUpdateMediaAssetRequest();
|
||||
payload.setVisibility("PRIVATE");
|
||||
|
||||
AdminMediaAssetDto updated = service.updateAsset(uploaded.getId(), payload);
|
||||
|
||||
assertEquals("PRIVATE", updated.getVisibility());
|
||||
assertFalse(Files.exists(publicFile));
|
||||
assertTrue(Files.exists(storageRoot.resolve("private").resolve(thumbJpeg.getStorageKey())));
|
||||
assertTrue(updated.getVariants().stream()
|
||||
.filter(variant -> !"ORIGINAL".equals(variant.getFormat()))
|
||||
.allMatch(variant -> variant.getPublicUrl() == null));
|
||||
}
|
||||
|
||||
private MediaAsset seedAsset(String visibility) {
|
||||
MediaAsset asset = new MediaAsset();
|
||||
asset.setId(UUID.randomUUID());
|
||||
asset.setOriginalFilename("asset.png");
|
||||
asset.setStorageKey("2026/03/" + UUID.randomUUID() + "/original.png");
|
||||
asset.setMimeType("image/png");
|
||||
asset.setFileSizeBytes(123L);
|
||||
asset.setSha256Hex("a".repeat(64));
|
||||
asset.setWidthPx(1200);
|
||||
asset.setHeightPx(800);
|
||||
asset.setStatus("READY");
|
||||
asset.setVisibility(visibility);
|
||||
asset.setCreatedAt(OffsetDateTime.now());
|
||||
asset.setUpdatedAt(OffsetDateTime.now());
|
||||
return asset;
|
||||
}
|
||||
|
||||
private MediaAsset persistAsset(MediaAsset asset) {
|
||||
assets.put(asset.getId(), asset);
|
||||
return asset;
|
||||
}
|
||||
|
||||
private MediaVariant persistVariant(MediaVariant variant) {
|
||||
if (variant.getId() == null) {
|
||||
variant.setId(UUID.randomUUID());
|
||||
}
|
||||
variants.put(variant.getId(), variant);
|
||||
return variant;
|
||||
}
|
||||
|
||||
private MediaUsage persistUsage(MediaUsage usage) {
|
||||
if (usage.getId() == null) {
|
||||
usage.setId(UUID.randomUUID());
|
||||
}
|
||||
usages.put(usage.getId(), usage);
|
||||
return usage;
|
||||
}
|
||||
|
||||
private List<MediaVariant> variantsForAssets(Collection<UUID> assetIds) {
|
||||
return variants.values().stream()
|
||||
.filter(variant -> assetIds.contains(variant.getMediaAsset().getId()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<MediaUsage> usagesForAssets(Collection<UUID> assetIds) {
|
||||
return usages.values().stream()
|
||||
.filter(usage -> assetIds.contains(usage.getMediaAsset().getId()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user