package com.music.service; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeTrue; /** * {@link AudioValidationService} 的单元与集成测试。 * *
依赖 FFprobe/FFmpeg 的测试通过 {@link #assumeFfmpegFfprobe()} 守卫, * 工具不可用时自动跳过。
*/ class AudioValidationServiceTest { private static boolean FFMPEG_AVAILABLE; private final AudioValidationService service = new AudioValidationService(); @BeforeAll static void checkFfmpegFfprobe() throws Exception { try { ProcessBuilder pb = new ProcessBuilder("ffmpeg", "-version"); pb.redirectErrorStream(true); Process p = pb.start(); boolean ok = p.waitFor(5, TimeUnit.SECONDS); FFMPEG_AVAILABLE = ok && p.exitValue() == 0; if (!ok) p.destroyForcibly(); } catch (Exception e) { FFMPEG_AVAILABLE = false; } if (FFMPEG_AVAILABLE) { // ffprobe 通常随 ffmpeg 一起安装 try { ProcessBuilder pb = new ProcessBuilder("ffprobe", "-version"); pb.redirectErrorStream(true); Process p = pb.start(); boolean ok = p.waitFor(5, TimeUnit.SECONDS); FFMPEG_AVAILABLE = ok && p.exitValue() == 0; if (!ok) p.destroyForcibly(); } catch (Exception e) { FFMPEG_AVAILABLE = false; } } } private static void assumeFfmpegFfprobe() { assumeTrue(FFMPEG_AVAILABLE, "此测试需要 ffmpeg 和 ffprobe"); } @AfterEach void clearProperties() { System.clearProperty(AudioValidationService.FFPROBE_BIN_PROPERTY); System.clearProperty(AudioValidationService.FFMPEG_BIN_PROPERTY); System.clearProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY); System.clearProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY); } // ========== 可执行路径解析 ========== @Test void getFfprobeCommand_defaultIsFfprobe() { assertEquals("ffprobe", service.getFfprobeCommand()); } @Test void getFfprobeCommand_respectsSystemProperty() { System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/custom/ffprobe"); assertEquals("/custom/ffprobe", service.getFfprobeCommand()); } @Test void getFfmpegCommand_defaultIsFfmpeg() { assertEquals("ffmpeg", service.getFfmpegCommand()); } @Test void getFfmpegCommand_respectsSystemProperty() { System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/custom/ffmpeg"); assertEquals("/custom/ffmpeg", service.getFfmpegCommand()); } // ========== 超时解析 ========== @Test void resolveProbeTimeout_default() { assertEquals(30, service.resolveProbeTimeout()); } @Test void resolveProbeTimeout_respectsSystemProperty() { System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "15"); assertEquals(15, service.resolveProbeTimeout()); } @Test void resolveProbeTimeout_ignoresInvalidProperty() { System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "not-a-number"); assertEquals(30, service.resolveProbeTimeout()); } @Test void resolveDecodeTimeout_default() { assertEquals(300, service.resolveDecodeTimeout()); } @Test void resolveDecodeTimeout_respectsSystemProperty() { System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "60"); assertEquals(60, service.resolveDecodeTimeout()); } @Test void resolveDecodeTimeout_ignoresInvalidProperty() { System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, ""); assertEquals(300, service.resolveDecodeTimeout()); } // ========== 超时解析(零值/负值回退) ========== @Test void resolveProbeTimeout_negativeFallsBackToDefault() { System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "-1"); assertEquals(30, service.resolveProbeTimeout()); } @Test void resolveProbeTimeout_zeroFallsBackToDefault() { System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "0"); assertEquals(30, service.resolveProbeTimeout()); } @Test void resolveDecodeTimeout_negativeFallsBackToDefault() { System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "-5"); assertEquals(300, service.resolveDecodeTimeout()); } @Test void resolveDecodeTimeout_zeroFallsBackToDefault() { System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "0"); assertEquals(300, service.resolveDecodeTimeout()); } // ========== ValidationResult ========== @Test void validationResult_diagnosticCappedAtMaxLength() { String longDiag = new String(new char[600]).replace('\0', 'X'); AudioValidationService.ValidationResult r = new AudioValidationService.ValidationResult(false, longDiag); assertEquals(512, r.getDiagnostic().length()); assertFalse(r.isValid()); } @Test void validationResult_nullDiagnosticBecomesEmpty() { AudioValidationService.ValidationResult r = new AudioValidationService.ValidationResult(true, null); assertTrue(r.isValid()); assertEquals("", r.getDiagnostic()); } // ========== 集成测试(需要 FFmpeg + FFprobe) ========== @Test void validMp3_passesValidation() throws Exception { assumeFfmpegFfprobe(); Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "test.mp3"); AudioValidationService.ValidationResult r = service.validate(mp3); assertTrue(r.isValid(), "有效 MP3 应通过验证,诊断: " + r.getDiagnostic()); } finally { deleteDirectory(tmpDir); } } @Test void nonAudioFile_failsProbe() throws Exception { assumeFfmpegFfprobe(); Path tmpDir = Files.createTempDirectory("val-test-"); try { Path garbage = tmpDir.resolve("notaudio.mp3"); Files.write(garbage, new byte[]{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}); AudioValidationService.ValidationResult r = service.validate(garbage); assertFalse(r.isValid(), "非音频文件应验证失败"); } finally { deleteDirectory(tmpDir); } } @Test void decode_failsWhenFfmpegExitsNonZero() throws Exception { assumeFfmpegFfprobe(); Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "test.mp3"); // 使用 /bin/false 模拟 ffmpeg 解码失败(ffprobe 使用真实工具先通过探测) System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/bin/false"); AudioValidationService.ValidationResult r = service.validate(mp3); assertFalse(r.isValid(), "ffmpeg 退出码非零时应验证失败"); assertTrue(r.getDiagnostic().contains("音频解码失败"), "诊断信息应包含解码失败提示,实际: " + r.getDiagnostic()); } finally { deleteDirectory(tmpDir); } } @Test void decode_failsWhenOutputNonEmptyEvenIfExitZero() throws Exception { assumeFfmpegFfprobe(); Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "test.mp3"); // 创建一个模拟脚本:输出错误诊断到 stderr 但退出码为 0 // 这模拟了 FFmpeg 6.1.1 中部分解码错误(如 invalid residual) // 仅输出到 error 级别而不设置非零退出码的情况 Path fakeFfmpeg = tmpDir.resolve("fake_ffmpeg.sh"); Files.write(fakeFfmpeg, Arrays.asList( "#!/bin/sh", "echo 'decoder error: invalid residual' >&2", "exit 0" )); fakeFfmpeg.toFile().setExecutable(true); System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, fakeFfmpeg.toString()); AudioValidationService.ValidationResult r = service.validate(mp3); assertFalse(r.isValid(), "ffmpeg 退出码 0 但有 error 输出时应验证失败"); assertTrue(r.getDiagnostic().contains("音频解码失败"), "诊断信息应包含解码失败提示,实际: " + r.getDiagnostic()); assertTrue(r.getDiagnostic().contains("invalid residual"), "诊断信息应包含原始的 error 输出,实际: " + r.getDiagnostic()); } finally { deleteDirectory(tmpDir); } } @Test void truncatedFile_failsProbe() throws Exception { assumeFfmpegFfprobe(); Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "trunc_test.mp3"); // 截断到 100 字节,保留 ID3v2 标签头但音频数据已不完整 long truncatedSize = Math.min(Files.size(mp3), 100); try (java.io.RandomAccessFile raf = new java.io.RandomAccessFile(mp3.toFile(), "rw")) { raf.setLength(truncatedSize); } AudioValidationService.ValidationResult r = service.validate(mp3); assertFalse(r.isValid(), "截断的文件应验证失败"); } finally { deleteDirectory(tmpDir); } } @Test void missingExecutable_failsGracefully() throws Exception { Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "test.mp3"); // 使用不存在的 ffprobe 路径 System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/nonexistent/ffprobe"); AudioValidationService.ValidationResult r = service.validate(mp3); assertFalse(r.isValid(), "ffprobe 不可用时验证应失败"); assertTrue(r.getDiagnostic().contains("FFprobe 执行失败")); } finally { deleteDirectory(tmpDir); } } @Test void missingFfmpeg_failsDecodeGracefully() throws Exception { Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "test.mp3"); // 使用不存在的 ffmpeg 路径 System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, "/nonexistent/ffmpeg"); AudioValidationService.ValidationResult r = service.validate(mp3); assertFalse(r.isValid(), "ffmpeg 不可用时验证应失败"); assertTrue(r.getDiagnostic().contains("FFmpeg 执行失败")); } finally { deleteDirectory(tmpDir); } } // ========== 超时集成测试(需要 FFmpeg + FFprobe) ========== @Test void probe_timesOut() throws Exception { assumeFfmpegFfprobe(); Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "test.mp3"); // 创建一个忽略参数并 sleep 30s 的挂起脚本 Path hangScript = tmpDir.resolve("hang.sh"); Files.write(hangScript, Arrays.asList("#!/bin/sh", "sleep 30")); hangScript.toFile().setExecutable(true); System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, hangScript.toString()); System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "1"); AudioValidationService.ValidationResult r = service.validate(mp3); assertFalse(r.isValid(), "探测超时应验证失败"); assertTrue(r.getDiagnostic().contains("音频探测超时"), "诊断应提示超时,实际: " + r.getDiagnostic()); } finally { deleteDirectory(tmpDir); } } @Test void decode_timesOut() throws Exception { assumeFfmpegFfprobe(); Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "test.mp3"); // 创建一个忽略参数并 sleep 30s 的挂起脚本 Path hangScript = tmpDir.resolve("hang.sh"); Files.write(hangScript, Arrays.asList("#!/bin/sh", "sleep 30")); hangScript.toFile().setExecutable(true); System.setProperty(AudioValidationService.FFMPEG_BIN_PROPERTY, hangScript.toString()); System.setProperty(AudioValidationService.DECODE_TIMEOUT_PROPERTY, "1"); AudioValidationService.ValidationResult r = service.validate(mp3); assertFalse(r.isValid(), "解码超时应验证失败"); assertTrue(r.getDiagnostic().contains("音频解码超时"), "诊断应提示解码超时,实际: " + r.getDiagnostic()); } finally { deleteDirectory(tmpDir); } } @Test void decodeCommand_usesXerrorAndMapArgs() throws Exception { assumeFfmpegFfprobe(); Path tmpDir = Files.createTempDirectory("val-test-"); try { Path mp3 = createValidMp3(tmpDir, "test.mp3"); List