From d2ee45e6b95af7db233ee3df030198a2a679237f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Thu, 11 Dec 2025 01:49:14 +0100 Subject: [PATCH] Tempcommit --- .../com/imgfloat/app/config/TaskConfig.java | 17 ++ .../com/imgfloat/app/model/AssetEvent.java | 9 + .../java/com/imgfloat/app/model/Channel.java | 9 +- .../app/repository/ChannelRepository.java | 7 + .../app/service/ChannelDirectoryService.java | 273 ++++++++++++++++-- .../app/ChannelDirectoryServiceTest.java | 88 +++++- ...hannelDirectoryServiceIntegrationTest.java | 66 +++++ 7 files changed, 436 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/imgfloat/app/config/TaskConfig.java create mode 100644 src/test/java/com/imgfloat/app/service/ChannelDirectoryServiceIntegrationTest.java diff --git a/src/main/java/com/imgfloat/app/config/TaskConfig.java b/src/main/java/com/imgfloat/app/config/TaskConfig.java new file mode 100644 index 0000000..e68b007 --- /dev/null +++ b/src/main/java/com/imgfloat/app/config/TaskConfig.java @@ -0,0 +1,17 @@ +package com.imgfloat.app.config; + +import org.springframework.boot.task.TaskExecutorBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; + +@Configuration +public class TaskConfig { + + @Bean(name = "assetTaskExecutor") + public TaskExecutor assetTaskExecutor(TaskExecutorBuilder builder) { + return builder + .threadNamePrefix("asset-optimizer-") + .build(); + } +} diff --git a/src/main/java/com/imgfloat/app/model/AssetEvent.java b/src/main/java/com/imgfloat/app/model/AssetEvent.java index 71b1134..e3219cd 100644 --- a/src/main/java/com/imgfloat/app/model/AssetEvent.java +++ b/src/main/java/com/imgfloat/app/model/AssetEvent.java @@ -34,6 +34,15 @@ public class AssetEvent { return event; } + public static AssetEvent updated(String channel, AssetView asset) { + AssetEvent event = new AssetEvent(); + event.type = Type.UPDATED; + event.channel = channel; + event.payload = asset; + event.assetId = asset.id(); + return event; + } + public static AssetEvent play(String channel, AssetView asset, boolean play) { AssetEvent event = new AssetEvent(); event.type = Type.PLAY; diff --git a/src/main/java/com/imgfloat/app/model/Channel.java b/src/main/java/com/imgfloat/app/model/Channel.java index 3d3f7c8..ed9fa4f 100644 --- a/src/main/java/com/imgfloat/app/model/Channel.java +++ b/src/main/java/com/imgfloat/app/model/Channel.java @@ -5,6 +5,7 @@ import jakarta.persistence.Column; import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.Index; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.PrePersist; @@ -18,13 +19,17 @@ import java.util.Set; import java.util.stream.Collectors; @Entity -@Table(name = "channels") +@Table(name = "channels", indexes = { + @Index(name = "idx_channels_broadcaster", columnList = "broadcaster") +}) public class Channel { @Id private String broadcaster; @ElementCollection(fetch = FetchType.EAGER) - @CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id")) + @CollectionTable(name = "channel_admins", + joinColumns = @JoinColumn(name = "channel_id"), + indexes = @Index(name = "idx_channel_admins_username", columnList = "admin_username")) @Column(name = "admin_username") private Set admins = new HashSet<>(); diff --git a/src/main/java/com/imgfloat/app/repository/ChannelRepository.java b/src/main/java/com/imgfloat/app/repository/ChannelRepository.java index 1e245c1..6dc3a86 100644 --- a/src/main/java/com/imgfloat/app/repository/ChannelRepository.java +++ b/src/main/java/com/imgfloat/app/repository/ChannelRepository.java @@ -3,5 +3,12 @@ package com.imgfloat.app.repository; import com.imgfloat.app.model.Channel; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface ChannelRepository extends JpaRepository { + List findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcaster); + + List findTop50ByOrderByBroadcasterAsc(); + + List findByAdminsContaining(String username); } diff --git a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java index 4c7b180..29f425a 100644 --- a/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java +++ b/src/main/java/com/imgfloat/app/service/ChannelDirectoryService.java @@ -19,6 +19,8 @@ import org.jcodec.common.model.Picture; import org.jcodec.scale.AWTUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -28,6 +30,8 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.net.URI; import java.net.URLConnection; import java.nio.ByteBuffer; import java.nio.file.InvalidPathException; @@ -35,12 +39,12 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.nio.file.StandardCopyOption; import java.util.Base64; import java.util.Collection; import java.util.Comparator; import java.util.List; import java.util.Locale; -import java.util.Objects; import java.util.Optional; import javax.imageio.ImageIO; import javax.imageio.ImageReader; @@ -58,17 +62,21 @@ public class ChannelDirectoryService { private static final int MIN_GIF_DELAY_MS = 20; private static final String PREVIEW_MEDIA_TYPE = "image/png"; private static final Path PREVIEW_ROOT = Paths.get("previews"); + private static final Path ASSET_ROOT = Paths.get("assets"); private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class); private final ChannelRepository channelRepository; private final AssetRepository assetRepository; private final SimpMessagingTemplate messagingTemplate; + private final TaskExecutor assetTaskExecutor; public ChannelDirectoryService(ChannelRepository channelRepository, AssetRepository assetRepository, - SimpMessagingTemplate messagingTemplate) { + SimpMessagingTemplate messagingTemplate, + @Qualifier("assetTaskExecutor") TaskExecutor assetTaskExecutor) { this.channelRepository = channelRepository; this.assetRepository = assetRepository; this.messagingTemplate = messagingTemplate; + this.assetTaskExecutor = assetTaskExecutor; } public Channel getOrCreateChannel(String broadcaster) { @@ -79,13 +87,13 @@ public class ChannelDirectoryService { public List searchBroadcasters(String query) { String normalizedQuery = normalize(query); - return channelRepository.findAll().stream() + if (normalizedQuery == null || normalizedQuery.isBlank()) { + return channelRepository.findTop50ByOrderByBroadcasterAsc().stream() + .map(Channel::getBroadcaster) + .toList(); + } + return channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(normalizedQuery).stream() .map(Channel::getBroadcaster) - .map(this::normalize) - .filter(Objects::nonNull) - .filter(name -> normalizedQuery == null || normalizedQuery.isBlank() || name.contains(normalizedQuery)) - .sorted() - .limit(50) .toList(); } @@ -134,39 +142,139 @@ public class ChannelDirectoryService { public Optional createAsset(String broadcaster, MultipartFile file) throws IOException { Channel channel = getOrCreateChannel(broadcaster); - byte[] bytes = file.getBytes(); - String mediaType = detectMediaType(file, bytes); - - OptimizedAsset optimized = optimizeAsset(bytes, mediaType); - if (optimized == null) { - return Optional.empty(); + byte[] bytes; + try (InputStream stream = file.getInputStream()) { + bytes = stream.readAllBytes(); } + String mediaType = detectMediaType(file, bytes); String name = Optional.ofNullable(file.getOriginalFilename()) .map(filename -> filename.replaceAll("^.*[/\\\\]", "")) .filter(s -> !s.isBlank()) .orElse("Asset " + System.currentTimeMillis()); + Asset asset = buildPlaceholderAsset(channel, name, bytes, mediaType); + if (asset == null) { + return Optional.empty(); + } + if (!shouldOptimizeAsync(mediaType)) { + OptimizedAsset optimized = optimizeAsset(bytes, mediaType); + if (optimized == null) { + return Optional.empty(); + } + applyOptimizedAsset(channel.getBroadcaster(), asset, optimized); + } - String dataUrl = "data:" + optimized.mediaType() + ";base64," + Base64.getEncoder().encodeToString(optimized.bytes()); - double width = optimized.width() > 0 ? optimized.width() : (optimized.mediaType().startsWith("audio/") ? 400 : 640); - double height = optimized.height() > 0 ? optimized.height() : (optimized.mediaType().startsWith("audio/") ? 80 : 360); - Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height); + assetRepository.save(asset); + AssetView view = AssetView.from(channel.getBroadcaster(), asset); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); + + if (shouldOptimizeAsync(mediaType)) { + enqueueOptimization(channel.getBroadcaster(), asset.getId(), bytes, mediaType); + } + + return Optional.of(view); + } + + private Asset buildPlaceholderAsset(Channel channel, String name, byte[] bytes, String mediaType) { + Dimension dimension = inferDimensions(bytes, mediaType); + double width = dimension.width() > 0 ? dimension.width() : (mediaType.startsWith("audio/") ? 400 : 640); + double height = dimension.height() > 0 ? dimension.height() : (mediaType.startsWith("audio/") ? 80 : 360); + Asset asset = new Asset(channel.getBroadcaster(), name, "", width, height); asset.setOriginalMediaType(mediaType); asset.setMediaType(optimized.mediaType()); - asset.setPreview(storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes())); + asset.setPreview(null); + asset.setUrl(storeAsset(channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType())); asset.setSpeed(1.0); - asset.setMuted(optimized.mediaType().startsWith("video/")); + asset.setMuted(mediaType.startsWith("video/")); asset.setAudioLoop(false); asset.setAudioDelayMillis(0); asset.setAudioSpeed(1.0); asset.setAudioPitch(1.0); asset.setAudioVolume(1.0); asset.setZIndex(nextZIndex(channel.getBroadcaster())); + return asset; + } - assetRepository.save(asset); - AssetView view = AssetView.from(channel.getBroadcaster(), asset); - messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view)); - return Optional.of(view); + private boolean shouldOptimizeAsync(String mediaType) { + return "image/gif".equalsIgnoreCase(mediaType) || mediaType.startsWith("video/"); + } + + private void enqueueOptimization(String broadcaster, String assetId, byte[] bytes, String mediaType) { + assetTaskExecutor.execute(() -> { + try { + OptimizedAsset optimized = optimizeAsset(bytes, mediaType); + if (optimized == null) { + return; + } + assetRepository.findById(assetId) + .filter(asset -> broadcaster.equals(asset.getBroadcaster())) + .ifPresent(asset -> { + boolean changed = applyOptimizedAsset(broadcaster, asset, optimized); + if (changed) { + assetRepository.save(asset); + AssetView view = AssetView.from(broadcaster, asset); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); + } + }); + } catch (Exception e) { + logger.warn("Unable to optimize asset {}", assetId, e); + } + }); + } + + private boolean applyOptimizedAsset(String broadcaster, Asset asset, OptimizedAsset optimized) { + if (optimized == null) { + return false; + } + boolean changed = false; + String optimizedUrl = toDataUrl(optimized.bytes(), optimized.mediaType()); + if (!optimizedUrl.equals(asset.getUrl())) { + asset.setUrl(optimizedUrl); + changed = true; + } + if (!Objects.equals(asset.getMediaType(), optimized.mediaType())) { + asset.setMediaType(optimized.mediaType()); + changed = true; + } + if (optimized.width() > 0 && optimized.height() > 0) { + asset.setWidth(optimized.width()); + asset.setHeight(optimized.height()); + changed = true; + } + asset.setMuted(optimized.mediaType().startsWith("video/")); + + String previousPreview = asset.getPreview(); + try { + asset.setPreview(storePreview(broadcaster, asset.getId(), optimized.previewBytes())); + if (!Objects.equals(previousPreview, asset.getPreview())) { + deletePreviewFile(previousPreview); + changed = changed || asset.getPreview() != null; + } + } catch (IOException e) { + logger.warn("Unable to store preview for asset {}", asset.getId(), e); + } + return changed; + } + + private Dimension inferDimensions(byte[] bytes, String mediaType) { + try { + if (mediaType.startsWith("video/")) { + return extractVideoDimensions(bytes); + } + if (mediaType.startsWith("image/")) { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + if (image != null) { + return new Dimension(image.getWidth(), image.getHeight()); + } + } + } catch (IOException e) { + logger.warn("Unable to infer dimensions for {}", mediaType, e); + } + return new Dimension(0, 0); + } + + private String toDataUrl(byte[] bytes, String mediaType) { + return "data:" + mediaType + ";base64," + Base64.getEncoder().encodeToString(bytes); } public Optional updateTransform(String broadcaster, String assetId, TransformRequest request) { @@ -244,6 +352,7 @@ public class ChannelDirectoryService { .filter(asset -> normalized.equals(asset.getBroadcaster())) .map(asset -> { deletePreviewFile(asset.getPreview()); + deleteAssetFile(asset.getUrl()); assetRepository.delete(asset); messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId)); return true; @@ -301,8 +410,7 @@ public class ChannelDirectoryService { return List.of(); } String login = username.toLowerCase(); - return channelRepository.findAll().stream() - .filter(channel -> channel.getAdmins().contains(login)) + return channelRepository.findByAdminsContaining(login).stream() .map(Channel::getBroadcaster) .toList(); } @@ -324,9 +432,18 @@ public class ChannelDirectoryService { } private Optional decodeAssetData(Asset asset) { - return decodeDataUrl(asset.getUrl()) + if (asset.getUrl() == null) { + return Optional.empty(); + } + + if (asset.getUrl().startsWith("data:")) { + return decodeDataUrl(asset.getUrl()) + .flatMap(content -> backfillAssetStorage(asset, content)); + } + + return loadAssetFromStore(asset) .or(() -> { - logger.warn("Unable to decode asset data for {}", asset.getId()); + logger.warn("Unable to read asset data for {}", asset.getId()); return Optional.empty(); }); } @@ -373,6 +490,88 @@ public class ChannelDirectoryService { } } + private String storeAsset(String broadcaster, String assetId, byte[] bytes, String mediaType) throws IOException { + Path directory = ASSET_ROOT.resolve(normalize(broadcaster)); + Files.createDirectories(directory); + String extension = extensionFor(mediaType); + Path assetFile = directory.resolve(assetId + (extension == null ? "" : ("." + extension))); + try (InputStream in = new ByteArrayInputStream(bytes)) { + Files.copy(in, assetFile, StandardCopyOption.REPLACE_EXISTING); + } + return assetFile.toUri().toString(); + } + + private Optional loadAssetFromStore(Asset asset) { + String location = asset.getUrl(); + if (location == null || location.isBlank()) { + return Optional.empty(); + } + try { + URI uri = URI.create(location); + Path path; + if (uri.getScheme() == null) { + path = Paths.get(location); + } else if ("file".equalsIgnoreCase(uri.getScheme())) { + path = Paths.get(uri); + } else { + logger.warn("Unsupported asset URI scheme {} for {}", uri.getScheme(), asset.getId()); + return Optional.empty(); + } + if (path == null || !Files.exists(path)) { + logger.warn("Asset location {} is not readable", location); + return Optional.empty(); + } + try (InputStream stream = Files.newInputStream(path)) { + byte[] bytes = stream.readAllBytes(); + String mediaType = asset.getMediaType() == null ? "application/octet-stream" : asset.getMediaType(); + return Optional.of(new AssetContent(bytes, mediaType)); + } + } catch (IOException | IllegalArgumentException e) { + logger.warn("Unable to read asset data from {}", location, e); + return Optional.empty(); + } + } + + private Optional backfillAssetStorage(Asset asset, AssetContent content) { + try { + String storedLocation = storeAsset(asset.getBroadcaster(), asset.getId(), content.bytes(), + asset.getMediaType() == null ? content.mediaType() : asset.getMediaType()); + asset.setUrl(storedLocation); + assetRepository.save(asset); + return Optional.of(new AssetContent(content.bytes(), + asset.getMediaType() == null ? content.mediaType() : asset.getMediaType())); + } catch (IOException e) { + logger.warn("Unable to backfill storage for {}", asset.getId(), e); + return Optional.empty(); + } + } + + private void deleteAssetFile(String location) { + if (location == null || location.isBlank()) { + return; + } + try { + URI uri = URI.create(location); + Path path; + if (uri.getScheme() == null) { + path = Paths.get(location); + } else if ("file".equalsIgnoreCase(uri.getScheme())) { + path = Paths.get(uri); + } else { + return; + } + if (path != null) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + logger.warn("Unable to delete asset file {}", path, e); + } + } + } catch (IllegalArgumentException e) { + logger.debug("Asset location {} is not a file path; skipping delete", location); + } + } + private String storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException { if (previewBytes == null || previewBytes.length == 0) { return null; @@ -439,6 +638,24 @@ public class ChannelDirectoryService { .orElse("application/octet-stream"); } + private String extensionFor(String mediaType) { + if (mediaType == null) { + return null; + } + return switch (mediaType.toLowerCase(Locale.ROOT)) { + case "image/png" -> "png"; + case "image/jpeg" -> "jpg"; + case "image/gif" -> "gif"; + case "video/mp4" -> "mp4"; + case "video/webm" -> "webm"; + case "video/quicktime" -> "mov"; + case "audio/mpeg" -> "mp3"; + case "audio/wav" -> "wav"; + case "audio/ogg" -> "ogg"; + default -> null; + }; + } + private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException { if ("image/gif".equalsIgnoreCase(mediaType)) { OptimizedAsset transcoded = transcodeGifToVideo(bytes); diff --git a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java index 3d83755..233ca12 100644 --- a/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java +++ b/src/test/java/com/imgfloat/app/ChannelDirectoryServiceTest.java @@ -11,19 +11,24 @@ import com.imgfloat.app.service.ChannelDirectoryService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.core.task.TaskExecutor; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.mock.web.MockMultipartFile; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; +import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ConcurrentHashMap; import javax.imageio.ImageIO; +import org.jcodec.api.awt.AWTSequenceEncoder; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -38,14 +43,18 @@ class ChannelDirectoryServiceTest { private SimpMessagingTemplate messagingTemplate; private ChannelRepository channelRepository; private AssetRepository assetRepository; + private RecordingTaskExecutor taskExecutor; + private Map channels; + private Map assets; @BeforeEach void setup() { messagingTemplate = mock(SimpMessagingTemplate.class); channelRepository = mock(ChannelRepository.class); assetRepository = mock(AssetRepository.class); + taskExecutor = new RecordingTaskExecutor(); setupInMemoryPersistence(); - service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate); + service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, taskExecutor); } @Test @@ -58,6 +67,41 @@ class ChannelDirectoryServiceTest { verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture()); } + @Test + void asyncGifOptimizationSwitchesToVideo() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "image.gif", "image/gif", largeGif()); + + long start = System.currentTimeMillis(); + AssetView created = service.createAsset("caster", file).orElseThrow(); + long duration = System.currentTimeMillis() - start; + + assertThat(duration).isLessThan(750L); + Asset placeholder = assets.get(created.id()); + assertThat(placeholder.getMediaType()).isEqualTo("image/gif"); + assertThat(placeholder.getPreview()).isNull(); + + taskExecutor.runAll(); + + Asset optimized = assets.get(created.id()); + assertThat(optimized.getMediaType()).isEqualTo("video/mp4"); + assertThat(optimized.getPreview()).isNotBlank(); + } + + @Test + void videoPreviewGeneratedInBackground() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "clip.mp4", "video/mp4", sampleVideo()); + + AssetView created = service.createAsset("caster", file).orElseThrow(); + Asset placeholder = assets.get(created.id()); + assertThat(placeholder.getPreview()).isNull(); + + taskExecutor.runAll(); + + Asset optimized = assets.get(created.id()); + assertThat(optimized.getPreview()).isNotBlank(); + assertThat(optimized.getMediaType()).isEqualTo("video/mp4"); + } + @Test void updatesTransformAndVisibility() throws Exception { MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); @@ -85,9 +129,33 @@ class ChannelDirectoryServiceTest { return out.toByteArray(); } + private byte[] largeGif() throws IOException { + BufferedImage image = new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB); + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + ImageIO.write(image, "gif", out); + return out.toByteArray(); + } + } + + private byte[] sampleVideo() throws IOException { + File temp = File.createTempFile("sample", ".mp4"); + temp.deleteOnExit(); + try { + AWTSequenceEncoder encoder = AWTSequenceEncoder.createSequenceEncoder(temp, 10); + for (int i = 0; i < 15; i++) { + BufferedImage frame = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB); + encoder.encodeImage(frame); + } + encoder.finish(); + return java.nio.file.Files.readAllBytes(temp.toPath()); + } finally { + temp.delete(); + } + } + private void setupInMemoryPersistence() { - Map channels = new ConcurrentHashMap<>(); - Map assets = new ConcurrentHashMap<>(); + channels = new ConcurrentHashMap<>(); + assets = new ConcurrentHashMap<>(); when(channelRepository.findById(anyString())) .thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0)))); @@ -122,4 +190,18 @@ class ChannelDirectoryServiceTest { .filter(asset -> !onlyVisible || !asset.isHidden()) .toList(); } + + private static class RecordingTaskExecutor implements TaskExecutor { + private final List queued = new CopyOnWriteArrayList<>(); + + @Override + public void execute(Runnable task) { + queued.add(task); + } + + void runAll() { + new ArrayList<>(queued).forEach(Runnable::run); + queued.clear(); + } + } } diff --git a/src/test/java/com/imgfloat/app/service/ChannelDirectoryServiceIntegrationTest.java b/src/test/java/com/imgfloat/app/service/ChannelDirectoryServiceIntegrationTest.java new file mode 100644 index 0000000..c17da08 --- /dev/null +++ b/src/test/java/com/imgfloat/app/service/ChannelDirectoryServiceIntegrationTest.java @@ -0,0 +1,66 @@ +package com.imgfloat.app.service; + +import com.imgfloat.app.model.Channel; +import com.imgfloat.app.repository.ChannelRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.messaging.simp.SimpMessagingTemplate; + +import com.imgfloat.app.repository.AssetRepository; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(SpringExtension.class) +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class ChannelDirectoryServiceIntegrationTest { + + @Autowired + private ChannelRepository channelRepository; + + private ChannelDirectoryService channelDirectoryService; + + @BeforeEach + void setUp() { + channelDirectoryService = new ChannelDirectoryService( + channelRepository, + Mockito.mock(AssetRepository.class), + Mockito.mock(SimpMessagingTemplate.class)); + } + + @Test + void searchBroadcastersFindsPartialMatchesCaseInsensitive() { + Channel alpha = new Channel("Alpha"); + Channel alphabeta = new Channel("AlphaBeta"); + Channel bravo = new Channel("Bravo"); + channelRepository.saveAll(List.of(alpha, alphabeta, bravo)); + + List results = channelDirectoryService.searchBroadcasters("lPh"); + + assertThat(results).containsExactly("alpha", "alphabeta"); + } + + @Test + void adminChannelsForFindsNormalizedAdminEntries() { + Channel alpha = new Channel("Alpha"); + alpha.addAdmin("ModOne"); + Channel bravo = new Channel("Bravo"); + bravo.addAdmin("modone"); + Channel charlie = new Channel("Charlie"); + charlie.addAdmin("other"); + + channelRepository.saveAll(List.of(alpha, bravo, charlie)); + + List adminChannels = channelDirectoryService.adminChannelsFor("MODONE").stream().sorted().toList(); + + assertThat(adminChannels).containsExactly("alpha", "bravo"); + } +}