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 library = new java.util.HashSet<>(); Set 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 library = new java.util.HashSet<>(); Set 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 library = new java.util.HashSet<>(); Set 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) {} } }