Files
MyTool/backend/src/test/java/com/music/service/AudioValidationServiceTest.java
T

603 lines
24 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} 的单元与集成测试。
*
* <p>依赖 FFprobe/FFmpeg 的测试通过 {@link #assumeFfmpegFfprobe()} 守卫,
* 工具不可用时自动跳过。</p>
*/
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<String> args = service.getDecodeCommandArgs(mp3);
assertTrue(args.contains("-xerror"),
"decode 命令应包含 -xerror,实际参数: " + args);
assertTrue(args.contains("-map"),
"decode 命令应包含 -map,实际参数: " + args);
int mapIndex = args.indexOf("-map");
assertTrue(mapIndex >= 0 && mapIndex + 1 < args.size(),
"-map 参数后应有值");
assertEquals("0:a:0", args.get(mapIndex + 1),
"-map 参数应指定 0:a:0");
assertTrue(args.contains("-f"),
"decode 命令应包含 -f");
assertTrue(args.contains("null"),
"解码 muxer 应为 null");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 排空测试(大量输出不阻塞) ==========
@Test
void drainer_doesNotDeadlockWithVerboseOutput() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("val-test-");
try {
// 创建一个输出远大于操作系统管道缓冲区(通常 64KB)的脚本
Path verboseScript = tmpDir.resolve("verbose.sh");
Files.write(verboseScript, Arrays.asList(
"#!/bin/sh",
"# 输出 ~200KB 数据以填满管道缓冲区",
"yes A | head -c 200000",
"exit 0"
));
verboseScript.toFile().setExecutable(true);
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, verboseScript.toString());
System.setProperty(AudioValidationService.PROBE_TIMEOUT_PROPERTY, "10");
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 需要 ffprobe 正常可用供 decode 阶段使用,但 probe 阶段使用 verbose 脚本
AudioValidationService.ValidationResult r = service.validate(mp3);
// verbose 脚本输出大量 'A',不含 "audio",预计探测失败
assertFalse(r.isValid(), "非音频输出应验证失败");
// 诊断信息必须被截断到 MAX_DIAGNOSTIC_LENGTH
assertTrue(r.getDiagnostic().length() <= 512,
"诊断信息不应超过 512 字节,实际: " + r.getDiagnostic().length());
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 中断清理测试 ==========
@Test
void interruptDuringProbe_cleansUpAndReinterrupts() throws Exception {
Path tmpDir = Files.createTempDirectory("val-test-");
try {
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, "10");
Path mp3 = createValidMp3(tmpDir, "test.mp3");
// 先设置中断标志,再调用 validate
// p.waitFor(timeout, ...) 会在中断标志已设置时立即抛出 InterruptedException
Thread.currentThread().interrupt();
AudioValidationService.ValidationResult r = service.validate(mp3);
assertFalse(r.isValid(), "中断后验证应失败");
// runProcess 应在清理后重新设置中断标志
assertTrue(Thread.interrupted(), "中断标志应被保留并由 Thread.interrupted() 清除");
} finally {
Thread.interrupted(); // 清除主线程残留中断
deleteDirectory(tmpDir);
}
}
// ========== FFprobe 后备元数据读取 ==========
@Test
void getMetadataCommandArgs_usesStructuredJson() {
Path f = Paths.get("/tmp/song.m4a");
List<String> args = service.getMetadataCommandArgs(f);
assertTrue(args.contains("-print_format"));
assertTrue(args.contains("json"));
assertTrue(args.contains("-show_format"));
assertTrue(args.contains("-show_streams"));
// 绝不使用任何人类可读文本或文件名猜测选项
assertFalse(args.contains("-of"));
assertEquals(f.toAbsolutePath().toString(), args.get(args.size() - 1));
}
@Test
void readMetadata_returnsFullTags() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("probe-meta-");
try {
Path m4a = createTaggedM4a(tmpDir, "song.m4a",
"ProbeTitle", "ProbeArtist", "ProbeAlbum");
AudioValidationService.ProbeMetadata meta = service.readMetadata(m4a);
assertTrue(meta.isAvailable(), "应成功读取元数据");
assertEquals("ProbeTitle", meta.getFirst("title"));
assertEquals("ProbeArtist", meta.getFirst("artist"));
assertEquals("ProbeAlbum", meta.getFirst("album"));
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void readMetadata_missingAlbumReturnsEmpty() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("probe-meta-");
try {
// 仅写 title/artist,故意不写 album
Path m4a = tmpDir.resolve("noalbum.m4a");
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", "0.3", "-c:a", "aac",
"-metadata", "title=T", "-metadata", "artist=A",
m4a.toAbsolutePath().toString());
pb.redirectErrorStream(true);
Process p = pb.start();
readAllBytes(p.getInputStream());
p.waitFor(15, TimeUnit.SECONDS);
AudioValidationService.ProbeMetadata meta = service.readMetadata(m4a);
assertTrue(meta.isAvailable());
assertEquals("", meta.getFirst("album"), "缺失的 album 应返回空字符串");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void readMetadata_nonAudioFileUnavailable() throws Exception {
assumeFfmpegFfprobe();
Path tmpDir = Files.createTempDirectory("probe-meta-");
try {
Path bogus = tmpDir.resolve("bogus.m4a");
Files.write(bogus, "not an audio file".getBytes(StandardCharsets.UTF_8));
AudioValidationService.ProbeMetadata meta = service.readMetadata(bogus);
assertFalse(meta.isAvailable(), "无法识别的文件应返回 unavailable");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void readMetadata_ffprobeFailureUnavailable() {
// 强制 ffprobe 指向失败命令
System.setProperty(AudioValidationService.FFPROBE_BIN_PROPERTY, "/bin/false");
AudioValidationService.ProbeMetadata meta =
service.readMetadata(Paths.get("/tmp/whatever.m4a"));
assertFalse(meta.isAvailable(), "ffprobe 失败应返回 unavailable");
}
/**
* 使用 FFmpeg 创建一个带标签的有效短 M4A(AAC)文件。
*/
private static Path createTaggedM4a(Path dir, String fileName,
String title, String artist, String album) throws Exception {
Path out = dir.resolve(fileName);
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y", "-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", "0.3", "-c:a", "aac",
"-metadata", "title=" + title,
"-metadata", "artist=" + artist,
"-metadata", "album=" + album,
out.toAbsolutePath().toString());
pb.redirectErrorStream(true);
Process p = pb.start();
readAllBytes(p.getInputStream());
boolean finished = p.waitFor(15, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
throw new RuntimeException("ffmpeg 创建 M4A 超时");
}
if (p.exitValue() != 0) {
throw new RuntimeException("ffmpeg 创建 M4A 失败 (exit=" + p.exitValue() + ")");
}
return out;
}
// ========== 帮助方法 ==========
/**
* 使用 FFmpeg 创建一个带标签的有效短 MP3 文件。
*/
private static Path createValidMp3(Path dir, String fileName) throws Exception {
Path out = dir.resolve(fileName);
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-y",
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.2",
"-b:a", "32k",
"-write_xing", "0",
"-id3v2_version", "3",
"-metadata", "title=TestTitle",
"-metadata", "artist=TestArtist",
"-metadata", "album=TestAlbum",
out.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process p = pb.start();
readAllBytes(p.getInputStream());
boolean finished = p.waitFor(15, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
throw new RuntimeException("ffmpeg 创建 MP3 超时");
}
if (p.exitValue() != 0) {
throw new RuntimeException("ffmpeg 创建 MP3 失败 (exit=" + p.exitValue() + ")");
}
return out;
}
private static byte[] readAllBytes(InputStream is) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] tmp = new byte[4096];
int n;
while ((n = is.read(tmp)) != -1) {
buf.write(tmp, 0, n);
}
return buf.toByteArray();
}
private static void deleteDirectory(Path dir) {
try {
if (Files.exists(dir)) {
Files.walk(dir)
.sorted((a, b) -> -a.compareTo(b))
.forEach(p -> {
try { Files.deleteIfExists(p); } catch (Exception ignored) {}
});
}
} catch (Exception ignored) {}
}
}