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

526 lines
21 KiB
Java

package com.music.service;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.mock;
class IngestServiceInternalTest {
@Test
void identityKeyEquality() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class);
ctor.setAccessible(true);
Object a = ctor.newInstance("artist1", "album1", 1, 3, "title1");
Object b = ctor.newInstance("artist1", "album1", 1, 3, "title1");
Object c = ctor.newInstance("artist2", "album1", 1, 3, "title1");
Object d = ctor.newInstance("artist1", "album1", 1, 4, "title1");
assertEquals(a, b);
assertEquals(a.hashCode(), b.hashCode());
assertNotEquals(a, c);
assertNotEquals(a, d);
}
@Test
void identityKeyConstructorAndGetters() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class);
ctor.setAccessible(true);
Object key = ctor.newInstance("test artist", "test album", 2, 5, "test title");
Method toString = ikClass.getDeclaredMethod("toString");
String str = (String) toString.invoke(key);
assertTrue(str.contains("test artist"));
assertTrue(str.contains("test album"));
assertTrue(str.contains("2"));
assertTrue(str.contains("5"));
assertTrue(str.contains("test title"));
}
@Test
void sanitizePathComponentRemovesInvalidChars() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("sanitizePathComponent", String.class);
m.setAccessible(true);
assertEquals("Hello World", m.invoke(service, "Hello World"));
assertEquals("A_B", m.invoke(service, "A:B"));
assertEquals("A_B", m.invoke(service, "A/B"));
assertEquals("_", m.invoke(service, ""));
assertEquals("_", m.invoke(service, (String) null));
assertEquals("Trimmed", m.invoke(service, " Trimmed "));
}
@Test
void parseIntHandlesVariousInputs() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("parseInt", String.class, int.class);
m.setAccessible(true);
assertEquals(3, (int) m.invoke(service, "3", 0));
assertEquals(5, (int) m.invoke(service, "05", 0));
assertEquals(0, (int) m.invoke(service, (String) null, 0));
assertEquals(0, (int) m.invoke(service, "", 0));
assertEquals(42, (int) m.invoke(service, " 42 ", 0));
assertEquals(1, (int) m.invoke(service, "1/10", 0));
assertEquals(0, (int) m.invoke(service, "abc", 0));
}
@Test
void extractYearParsesDates() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("extractYear", String.class);
m.setAccessible(true);
assertEquals("2024", m.invoke(service, "2024"));
assertEquals("1999", m.invoke(service, "1999-01-01"));
assertEquals("2000", m.invoke(service, "2000-12-31"));
assertEquals("", m.invoke(service, (String) null));
assertEquals("", m.invoke(service, ""));
assertEquals("", m.invoke(service, "abc"));
}
@Test
void getExtensionWorksCorrectly() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("getExtension", String.class);
m.setAccessible(true);
assertEquals("flac", m.invoke(service, "song.flac"));
assertEquals("mp3", m.invoke(service, "song.MP3"));
assertEquals("wav", m.invoke(service, "song.Wav"));
assertNull(m.invoke(service, "noext"));
assertNull(m.invoke(service, ".hidden"));
}
@Test
void getBaseNameWorksCorrectly() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("getBaseName", String.class);
m.setAccessible(true);
assertEquals("song", m.invoke(service, "song.flac"));
assertEquals("song.name", m.invoke(service, "song.name.flac"));
assertEquals("noext", m.invoke(service, "noext"));
assertEquals("", m.invoke(service, ""));
}
@Test
void resolveUniqueFileCreatesNonConflictingPaths() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("resolveUniqueFile", Path.class, String.class);
m.setAccessible(true);
Path tmpDir = Files.createTempDirectory("ingest-unique-");
Path result = (Path) m.invoke(service, tmpDir, "test.flac");
assertEquals(tmpDir.resolve("test.flac"), result);
// 已存在时应该添加后缀
Files.write(result, "existing".getBytes());
Path result2 = (Path) m.invoke(service, tmpDir, "test.flac");
assertEquals(tmpDir.resolve("test (1).flac"), result2);
}
@Test
void isLosslessFormatDetectsCorrectFormats() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("isLosslessFormat", Path.class);
m.setAccessible(true);
assertTrue((boolean) m.invoke(service, Paths.get("test.wav")));
assertTrue((boolean) m.invoke(service, Paths.get("test.ape")));
assertTrue((boolean) m.invoke(service, Paths.get("test.aiff")));
assertTrue((boolean) m.invoke(service, Paths.get("test.wv")));
assertTrue((boolean) m.invoke(service, Paths.get("test.tta")));
assertFalse((boolean) m.invoke(service, Paths.get("test.flac")));
assertFalse((boolean) m.invoke(service, Paths.get("test.mp3")));
assertFalse((boolean) m.invoke(service, Paths.get("test.txt")));
}
@Test
void isAudioFileDetectsAllSupportedFormats() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("isAudioFile", Path.class);
m.setAccessible(true);
assertTrue((boolean) m.invoke(service, Paths.get("test.flac")));
assertTrue((boolean) m.invoke(service, Paths.get("test.mp3")));
assertTrue((boolean) m.invoke(service, Paths.get("test.wav")));
assertTrue((boolean) m.invoke(service, Paths.get("test.ape")));
assertTrue((boolean) m.invoke(service, Paths.get("test.m4a")));
assertFalse((boolean) m.invoke(service, Paths.get("test.txt")));
assertFalse((boolean) m.invoke(service, Paths.get("test.jpg")));
}
/**
* 通过反射获取嵌套类 IdentityKey
*/
private Class<?> getIdentityKeyClass() throws ClassNotFoundException {
return Class.forName("com.music.service.IngestService$IdentityKey");
}
@Test
void identityKeyWithMd5Constructor() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
// 6 参数构造器:artist, album, disc, track, title, md5
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Object keyA = ctor6.newInstance("art", "alb", 1, 2, "ttl", "abc123");
Object keyB = ctor6.newInstance("art", "alb", 1, 2, "ttl", "def456");
// equals/hashCode 应忽略 MD5,仅基于元数据
assertEquals(keyA, keyB);
assertEquals(keyA.hashCode(), keyB.hashCode());
}
@Test
void identityKeyMd5StoredAndAccessible() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Object key = ctor6.newInstance("a", "b", 0, 0, "c", "md5hashvalue");
Method toString = ikClass.getDeclaredMethod("toString");
String str = (String) toString.invoke(key);
assertTrue(str.contains("a|b|0|0|c"));
}
@Test
void computeMd5ReturnsNonEmptyString() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Method m = IngestService.class.getDeclaredMethod("computeMd5", Path.class);
m.setAccessible(true);
Path tmpFile = Files.createTempFile("ingest-md5-", ".tmp");
Files.write(tmpFile, "hello test content".getBytes());
String md5 = (String) m.invoke(service, tmpFile);
assertNotNull(md5);
assertEquals(32, md5.length());
assertTrue(md5.matches("[0-9a-f]{32}"));
// 相同内容 → 相同 MD5
String md5b = (String) m.invoke(service, tmpFile);
assertEquals(md5, md5b);
// 不同内容 → 不同 MD5
Path tmpFile2 = Files.createTempFile("ingest-md5-", ".tmp");
Files.write(tmpFile2, "different content".getBytes());
String md5c = (String) m.invoke(service, tmpFile2);
assertNotEquals(md5, md5c);
}
@Test
void identityKeyDifferentMetadataNotEqual() throws Exception {
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Object key1 = ctor6.newInstance("artist1", "album1", 1, 1, "title1", "sameMd5");
Object key2 = ctor6.newInstance("artist2", "album1", 1, 1, "title1", "sameMd5");
Object key3 = ctor6.newInstance("artist1", "album2", 1, 1, "title1", "sameMd5");
assertNotEquals(key1, key2);
assertNotEquals(key1, key3);
}
@Test
void validatePathsRejectsSamePaths() throws Exception {
Path p = Paths.get("/tmp/ingest-test");
String inputVsLib = IngestService.validatePaths(p, p, Paths.get("/tmp/ingest-test-rej"));
assertNotNull(inputVsLib);
assertTrue(inputVsLib.contains("不能是同一目录"));
String libVsRej = IngestService.validatePaths(Paths.get("/tmp/ingest-test-in"), p, p);
assertNotNull(libVsRej);
assertTrue(libVsRej.contains("不能是同一目录"));
}
@Test
void validatePathsRejectsNestedPaths() throws Exception {
Path root = Paths.get("/tmp/ingest-nest-test");
Path nested = root.resolve("sub");
String libInInput = IngestService.validatePaths(root, nested, Paths.get("/tmp/ingest-nest-rej"));
assertNotNull(libInInput);
assertTrue(libInInput.contains("不能位于") || libInInput.contains("不能是同一"));
String rejInInput = IngestService.validatePaths(root, Paths.get("/tmp/ingest-nest-lib"), nested);
assertNotNull(rejInInput);
assertTrue(rejInInput.contains("不能位于") || rejInInput.contains("不能是同一"));
}
@Test
void validatePathsAcceptsValidPaths() throws Exception {
Path input = Paths.get("/tmp/ingest-valid-in");
Path lib = Paths.get("/tmp/ingest-valid-lib");
Path rej = Paths.get("/tmp/ingest-valid-rej");
assertNull(IngestService.validatePaths(input, lib, rej));
}
@Test
void checkDuplicateReturnsNullForNewIdentity() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Set<Object> library = new java.util.HashSet<>();
Set<Object> batch = new java.util.HashSet<>();
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
m.setAccessible(true);
Object newKey = ctor6.newInstance("new", "album", 1, 1, "title", "md5_1");
// 两个集合都为空 → 不重复
Object result = m.invoke(service, newKey, library, batch);
assertNull(result);
}
@Test
void checkDuplicateDetectsMetadataMatch() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Set<Object> library = new java.util.HashSet<>();
Set<Object> batch = new java.util.HashSet<>();
Object existing = ctor6.newInstance("artist", "album", 1, 3, "title", "md5_existing");
library.add(existing);
Object dupe = ctor6.newInstance("artist", "album", 1, 3, "title", "md5_other");
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
m.setAccessible(true);
Object result = m.invoke(service, dupe, library, batch);
assertNotNull(result);
assertEquals("metadata", result);
}
@Test
void checkDuplicateDetectsMd5Fallback() throws Exception {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Class<?> ikClass = getIdentityKeyClass();
Constructor<?> ctor6 = ikClass.getDeclaredConstructor(String.class, String.class, int.class, int.class, String.class, String.class);
ctor6.setAccessible(true);
Set<Object> library = new java.util.HashSet<>();
Set<Object> batch = new java.util.HashSet<>();
// 元数据不同但 MD5 相同
Object existing = ctor6.newInstance("artist1", "album1", 1, 1, "title1", "same_md5_hash");
library.add(existing);
Object dupByMd5 = ctor6.newInstance("artist2", "album2", 2, 2, "title2", "same_md5_hash");
Method m = IngestService.class.getDeclaredMethod("checkDuplicate",
getIdentityKeyClass(), java.util.Set.class, java.util.Set.class);
m.setAccessible(true);
Object result = m.invoke(service, dupByMd5, library, batch);
assertNotNull(result);
assertEquals("md5", result);
}
// ========== codex-4-2: LRC 侧车文件传播 ==========
@Test
void moveToRejectedMovesLrcSidecar() throws Exception {
Path tmpDir = Files.createTempDirectory("ingest-lrc-propagate-");
try {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
// 创建模拟音频文件 + .lrc 侧车
Path audioFile = tmpDir.resolve("song.flac");
Files.write(audioFile, "audio-data".getBytes());
Path lrcFile = tmpDir.resolve("song.lrc");
Files.write(lrcFile, "[00:01]lyrics".getBytes());
Path rejectedRoot = tmpDir.resolve("Rejected");
Method m = IngestService.class.getDeclaredMethod(
"moveToRejected", Path.class, Path.class, String.class, String.class);
m.setAccessible(true);
m.invoke(service, audioFile, rejectedRoot, "Other", "song.flac");
// 音频被移到 Rejected/Other
assertTrue(Files.exists(rejectedRoot.resolve("Other/song.flac")),
"音频应被移到 Rejected/Other");
// LRC 侧车也被移到 Rejected/Other
assertTrue(Files.exists(rejectedRoot.resolve("Other/song.lrc")),
"LRC 侧车文件应被一同移到 Rejected/Other");
// 源文件不再存在
assertFalse(Files.exists(audioFile), "源音频应已被移动");
assertFalse(Files.exists(lrcFile), "源 LRC 应已被移动");
} finally {
deleteDirectory(tmpDir);
}
}
@Test
void moveToRejectedSkipsMissingLrc() throws Exception {
Path tmpDir = Files.createTempDirectory("ingest-lrc-skip-");
try {
IngestService service = new IngestService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
mock(TraditionalFilterService.class),
mock(ConfigService.class)
);
Path audioFile = tmpDir.resolve("track.flac");
Files.write(audioFile, "data".getBytes());
// 故意不创建 .lrc 文件
Path rejectedRoot = tmpDir.resolve("Rej");
Method m = IngestService.class.getDeclaredMethod(
"moveToRejected", Path.class, Path.class, String.class, String.class);
m.setAccessible(true);
m.invoke(service, audioFile, rejectedRoot, "Duplicate", "track.flac");
assertTrue(Files.exists(rejectedRoot.resolve("Duplicate/track.flac")));
// 不应报错(无 LRC 时静默跳过)
} finally {
deleteDirectory(tmpDir);
}
}
// ========== codex-4-3: 唯一报告条目(相对路径键) ==========
@Test
void reportOutcomesKeyedByRelativePathAvoidsCollision() throws Exception {
Path tmpDir = Files.createTempDirectory("ingest-relpath-");
try {
// 模拟 Input 目录中有两个同名文件位于不同子目录
Path inputRoot = tmpDir.resolve("Input");
Path sub1 = inputRoot.resolve("album_a");
Path sub2 = inputRoot.resolve("album_b");
Files.createDirectories(sub1);
Files.createDirectories(sub2);
Path file1 = sub1.resolve("01.flac");
Path file2 = sub2.resolve("01.flac");
Files.write(file1, "a".getBytes());
Files.write(file2, "b".getBytes());
// relativize 应产生差异化路径
String rel1 = inputRoot.relativize(file1).toString();
String rel2 = inputRoot.relativize(file2).toString();
assertEquals("album_a/01.flac", rel1);
assertEquals("album_b/01.flac", rel2);
assertNotEquals(rel1, rel2, "不同子目录中的同名文件应有不同 relative 路径");
} finally {
deleteDirectory(tmpDir);
}
}
// ========== 工具方法 ==========
private 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) {}
}
}