feat(back-end): upload media

This commit is contained in:
2026-03-09 16:30:00 +01:00
parent 63804e7561
commit 9e306ea1d1
23 changed files with 2693 additions and 3 deletions

View File

@@ -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();
}
}