package com.ddf.vodsystem.services.media; import com.ddf.vodsystem.dto.ClipOptions; import com.ddf.vodsystem.dto.CommandOutput; import com.ddf.vodsystem.exceptions.FFMPEGException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.concurrent.ExecutionException; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class MetadataServiceTest { @Mock private CommandRunner commandRunner; private MetadataService metadataService; private static final Path INPUT = Path.of("/tmp/input.mp4"); @BeforeEach void setUp() { metadataService = new MetadataService(commandRunner); Thread.interrupted(); // clear any flag leaked from a previous test } @AfterEach void clearInterruptFlag() { Thread.interrupted(); } /** Wraps a raw ffprobe JSON payload into a successful CommandOutput. */ private CommandOutput ffprobeOutput(String json) { CommandOutput output = new CommandOutput(); output.addLine(json); return output; } private ClipOptions runMetadata() throws Exception { return metadataService.getVideoMetadata(INPUT).get(); } private void assertMetadataFailsWith(Class exceptionType, String messageFragment) { assertThatThrownBy(() -> metadataService.getVideoMetadata(INPUT).get()) .satisfies(thrown -> { Throwable cause = thrown instanceof ExecutionException ? thrown.getCause() : thrown; assertThat(cause).isInstanceOf(exceptionType); assertThat(cause.getMessage()).contains(messageFragment); }); } private void assertMetadataFailsWith(String messageFragment) { assertMetadataFailsWith(FFMPEGException.class, messageFragment); } // --------------------------------------------------------------- // Happy path / parsing behavior // --------------------------------------------------------------- @Test void getVideoMetadata_validJson_parsesAllFields() throws Exception { String json = """ { "streams": [ { "width": 1920, "height": 1080, "r_frame_rate": "30/1", "duration": "60.000000" } ], "format": { "size": "10485760", "duration": "60.000000" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); ClipOptions options = runMetadata(); assertThat(options.getStartPoint()).isEqualTo(0f); assertThat(options.getDuration()).isEqualTo(60.0f); assertThat(options.getWidth()).isEqualTo(1920); assertThat(options.getHeight()).isEqualTo(1080); assertThat(options.getFps()).isEqualTo(30.0f); assertThat(options.getFileSize()).isEqualTo(10485760L); } @Test void getVideoMetadata_ffprobeCommandIsCorrect() throws Exception { String json = """ { "streams": [ { "width": 1920, "height": 1080, "r_frame_rate": "30/1", "duration": "60.0" } ], "format": { "size": "1000", "duration": "60.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); runMetadata(); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); Mockito.verify(commandRunner).run(captor.capture()); List command = captor.getValue(); assertThat(command).startsWith("ffprobe"); assertThat(command).containsSequence("-print_format", "json"); assertThat(command).containsSequence("-select_streams", "v:0"); assertThat(command).containsSequence("-i", INPUT.toAbsolutePath().toString()); } @Test void getVideoMetadata_fractionalFrameRate_computesFps() throws Exception { String json = """ { "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "30000/1001", "duration": "10.0" } ], "format": { "size": "500", "duration": "10.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); ClipOptions options = runMetadata(); assertThat(options.getFps()).isCloseTo(29.97f, org.assertj.core.data.Offset.offset(0.01f)); } @Test void getVideoMetadata_integerFrameRate_parsesFps() throws Exception { String json = """ { "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ], "format": { "size": "500", "duration": "10.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); ClipOptions options = runMetadata(); assertThat(options.getFps()).isEqualTo(25.0f); } @Test void getVideoMetadata_zeroDenominatorFrameRate_fpsIsNull() throws Exception { String json = """ { "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "0/0", "duration": "10.0" } ], "format": { "size": "500", "duration": "10.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); ClipOptions options = runMetadata(); assertThat(options.getFps()).isNull(); } @Test void getVideoMetadata_missingFrameRate_fpsIsNull() throws Exception { String json = """ { "streams": [ { "width": 1280, "height": 720, "duration": "10.0" } ], "format": { "size": "500", "duration": "10.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); ClipOptions options = runMetadata(); assertThat(options.getFps()).isNull(); } @Test void getVideoMetadata_missingStreams_throwsFFMPEGException() throws Exception { String json = """ { "streams": [], "format": { "size": "500", "duration": "10.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); assertMetadataFailsWith("streams missing"); } @Test void getVideoMetadata_missingWidth_throwsFFMPEGException() throws Exception { String json = """ { "streams": [ { "height": 720, "r_frame_rate": "25", "duration": "10.0" } ], "format": { "size": "500", "duration": "10.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); assertMetadataFailsWith("width missing"); } @Test void getVideoMetadata_missingHeight_throwsFFMPEGException() throws Exception { String json = """ { "streams": [ { "width": 1280, "r_frame_rate": "25", "duration": "10.0" } ], "format": { "size": "500", "duration": "10.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); assertMetadataFailsWith("height missing"); } @Test void getVideoMetadata_missingFileSize_throwsFFMPEGException() throws Exception { String json = """ { "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ], "format": { "duration": "10.0" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); assertMetadataFailsWith("file size missing"); } @Test void getVideoMetadata_durationMissingEverywhere_throwsFFMPEGException() throws Exception { String json = """ { "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25" } ], "format": { "size": "500" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); assertMetadataFailsWith("duration missing"); } @Test void getVideoMetadata_commandThrowsIOException_completesExceptionally() throws Exception { when(commandRunner.run(anyList())).thenThrow(new IOException("ffprobe boom")); assertMetadataFailsWith(IOException.class, "ffprobe boom"); } @Test void getVideoMetadata_streamMissingDuration_fallsBackToFormatDuration() throws Exception { // format=duration is requested from ffprobe specifically as a fallback source, // used when the stream itself lacks "duration". String json = """ { "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25" } ], "format": { "size": "500", "duration": "45.5" } } """; when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); ClipOptions options = runMetadata(); assertThat(options.getDuration()).isEqualTo(45.5f); } @Test void getVideoMetadata_largeFileSize_preservesExactByteCount() throws Exception { // fileSize is a Long specifically so byte counts for large files (any real // video is well past the 2^24 float-precision boundary) survive exactly. long exactBytes = 16_777_217L; // smallest integer a 32-bit float cannot represent exactly String json = """ { "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ], "format": { "size": "%d", "duration": "10.0" } } """.formatted(exactBytes); when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); ClipOptions options = runMetadata(); assertThat(options.getFileSize()).isEqualTo(exactBytes); } @Test void getVideoMetadata_plainIOException_doesNotSetThreadInterruptFlag() throws Exception { when(commandRunner.run(anyList())).thenThrow(new IOException("ffprobe boom")); assertMetadataFailsWith(IOException.class, "ffprobe boom"); assertThat(Thread.currentThread().isInterrupted()) .as("a plain IOException must not leave the calling thread's interrupt flag set") .isFalse(); } @Test void getVideoMetadata_interruptedException_setsThreadInterruptFlag() throws Exception { when(commandRunner.run(anyList())).thenThrow(new InterruptedException("interrupted")); assertMetadataFailsWith(InterruptedException.class, "interrupted"); assertThat(Thread.currentThread().isInterrupted()).isTrue(); } }