Template
Add one-click Navidrome ingestion workflow
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ProgressMessageMappingTest {
|
||||
|
||||
@@ -76,4 +79,84 @@ class ProgressMessageMappingTest {
|
||||
assertEquals(3, pm.getUpgradedFiles().intValue());
|
||||
assertEquals(2, pm.getSkippedFiles().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ingestProgressContainsDedicatedFields() throws Exception {
|
||||
ProgressStore store = new ProgressStore();
|
||||
IngestService service = new IngestService(
|
||||
mock(SimpMessagingTemplate.class), store,
|
||||
mock(TraditionalFilterService.class),
|
||||
mock(ConfigService.class));
|
||||
|
||||
Method m = IngestService.class.getDeclaredMethod(
|
||||
"sendProgress", String.class, int.class, int.class,
|
||||
int.class, int.class, int.class, int.class,
|
||||
int.class, int.class, String.class, String.class, boolean.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(service, "t-ingest", 100, 50, 10, 5, 3, 2, 1, 4, "f.mp3", "msg", false);
|
||||
|
||||
ProgressMessage pm = store.get("t-ingest");
|
||||
assertEquals(10, pm.getIngestedFiles().intValue());
|
||||
assertEquals(5, pm.getDuplicateFiles().intValue());
|
||||
assertEquals(3, pm.getMissingMetadataFiles().intValue());
|
||||
assertEquals(2, pm.getUnreadableFiles().intValue());
|
||||
assertEquals(1, pm.getConversionFailedFiles().intValue());
|
||||
assertEquals(4, pm.getOtherRejectedFiles().intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ingestControllerRejectsConcurrentStarts() throws Exception {
|
||||
ConfigService cfgService = mock(ConfigService.class);
|
||||
when(cfgService.getBasePath()).thenReturn("/tmp");
|
||||
|
||||
com.music.controller.IngestController controller = new com.music.controller.IngestController(
|
||||
mock(IngestService.class),
|
||||
new ProgressStore(),
|
||||
cfgService);
|
||||
|
||||
// 模拟配置已设置
|
||||
com.music.dto.ConfigResponse cfgResp = new com.music.dto.ConfigResponse();
|
||||
cfgResp.setBasePath("/tmp");
|
||||
|
||||
java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running");
|
||||
runningField.setAccessible(true);
|
||||
AtomicBoolean running = (AtomicBoolean) runningField.get(controller);
|
||||
|
||||
// 设置为运行中
|
||||
running.set(true);
|
||||
|
||||
// 验证并发被拒绝
|
||||
com.music.exception.BusinessException ex = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
com.music.exception.BusinessException.class,
|
||||
() -> controller.start(new com.music.dto.IngestRequest())
|
||||
);
|
||||
assertEquals(409, ex.getCode());
|
||||
|
||||
// 重置
|
||||
running.set(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ingestControllerAcceptsSecondStartAfterCompletion() throws Exception {
|
||||
ConfigService cfgService = mock(ConfigService.class);
|
||||
when(cfgService.getBasePath()).thenReturn("/tmp");
|
||||
|
||||
ProgressStore store = new ProgressStore();
|
||||
com.music.controller.IngestController controller = new com.music.controller.IngestController(
|
||||
mock(IngestService.class),
|
||||
store,
|
||||
cfgService);
|
||||
|
||||
java.lang.reflect.Field runningField = com.music.controller.IngestController.class.getDeclaredField("running");
|
||||
runningField.setAccessible(true);
|
||||
AtomicBoolean running = (AtomicBoolean) runningField.get(controller);
|
||||
|
||||
// 模拟第一个任务完成:IngestService.finally 释放了锁
|
||||
running.set(true);
|
||||
running.set(false); // 模拟 finally 释放
|
||||
|
||||
// 第二个 start 应成功(没有 409 异常)
|
||||
com.music.common.Result<?> result = controller.start(new com.music.dto.IngestRequest());
|
||||
assertTrue(result.getCode() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user