Template
367 lines
15 KiB
Java
367 lines
15 KiB
Java
package com.music;
|
|
|
|
import com.music.common.Constants;
|
|
import com.music.controller.IngestController;
|
|
import com.music.exception.BusinessException;
|
|
import com.music.dto.IngestRequest;
|
|
import com.music.service.ConfigService;
|
|
import com.music.service.IngestService;
|
|
import com.music.service.IngestTaskStore;
|
|
import com.music.service.ProgressStore;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.mockito.Mock;
|
|
import org.mockito.MockedStatic;
|
|
import org.mockito.Mockito;
|
|
import org.mockito.MockitoAnnotations;
|
|
import org.springframework.mock.web.MockMultipartFile;
|
|
|
|
import java.io.IOException;
|
|
import java.nio.file.Files;
|
|
import java.nio.file.Path;
|
|
import java.util.concurrent.CountDownLatch;
|
|
import java.util.concurrent.ExecutorService;
|
|
import java.util.concurrent.Executors;
|
|
import java.util.concurrent.atomic.AtomicInteger;
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
import static org.mockito.Mockito.when;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
|
|
public class IngestUploadTest {
|
|
|
|
@Mock
|
|
private IngestService ingestService;
|
|
|
|
@Mock
|
|
private ProgressStore progressStore;
|
|
|
|
@Mock
|
|
private ConfigService configService;
|
|
|
|
@Mock
|
|
private IngestTaskStore taskStore;
|
|
|
|
private IngestController ingestController;
|
|
|
|
private Path tempDir;
|
|
private Path inputDir;
|
|
|
|
@BeforeEach
|
|
public void setUp() throws Exception {
|
|
MockitoAnnotations.openMocks(this);
|
|
|
|
tempDir = Files.createTempDirectory("upload-tmp");
|
|
inputDir = Files.createTempDirectory("music-input");
|
|
|
|
ingestController = new IngestController(ingestService, progressStore, configService, taskStore, tempDir.toString());
|
|
|
|
when(configService.getInputDir()).thenReturn(inputDir.toString());
|
|
when(configService.getBasePath()).thenReturn(inputDir.toString());
|
|
}
|
|
|
|
@AfterEach
|
|
public void tearDown() throws Exception {
|
|
if (Files.exists(tempDir)) {
|
|
try (java.util.stream.Stream<Path> s = Files.walk(tempDir)) {
|
|
s.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
|
|
try { Files.delete(p); } catch (IOException ignored) {}
|
|
});
|
|
}
|
|
}
|
|
if (Files.exists(inputDir)) {
|
|
try (java.util.stream.Stream<Path> s = Files.walk(inputDir)) {
|
|
s.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
|
|
try { Files.delete(p); } catch (IOException ignored) {}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
public void testInitCleansStalePartFiles() throws Exception {
|
|
Path part1 = tempDir.resolve("test1.part");
|
|
Path part2 = tempDir.resolve("test2.txt");
|
|
Files.write(part1, "test".getBytes());
|
|
Files.write(part2, "test".getBytes());
|
|
|
|
ingestController.init();
|
|
|
|
assertFalse(Files.exists(part1), "Stale .part file should be cleaned");
|
|
assertTrue(Files.exists(part2), "Non-.part file should not be cleaned");
|
|
}
|
|
|
|
@Test
|
|
public void testUploadSuccess() throws Exception {
|
|
MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes());
|
|
com.music.common.Result<com.music.dto.UploadFileResponse> result = ingestController.upload(file, "album/test.mp3");
|
|
|
|
assertEquals(0, result.getCode());
|
|
assertEquals("uploaded", result.getData().getStatus());
|
|
assertEquals("album/test.mp3", result.getData().getRelativePath());
|
|
assertTrue(result.getData().getSize() > 0);
|
|
|
|
Path targetPath = inputDir.resolve("album/test.mp3");
|
|
assertTrue(Files.exists(targetPath));
|
|
assertEquals("testdata", new String(Files.readAllBytes(targetPath)));
|
|
}
|
|
|
|
@Test
|
|
public void testUploadExtensions() throws Exception {
|
|
for (String ext : Constants.SUPPORTED_AUDIO_EXTENSIONS) {
|
|
MockMultipartFile file = new MockMultipartFile("file", "test." + ext, "audio/mpeg", "testdata".getBytes());
|
|
com.music.common.Result<com.music.dto.UploadFileResponse> result = ingestController.upload(file, "album/test." + ext);
|
|
assertEquals(0, result.getCode());
|
|
}
|
|
MockMultipartFile file = new MockMultipartFile("file", "test.lrc", "text/plain", "testdata".getBytes());
|
|
com.music.common.Result<com.music.dto.UploadFileResponse> result = ingestController.upload(file, "album/test.lrc");
|
|
assertEquals(0, result.getCode());
|
|
}
|
|
|
|
@Test
|
|
public void testUploadSkipExisting() throws Exception {
|
|
Path targetPath = inputDir.resolve("album/test.mp3");
|
|
Files.createDirectories(targetPath.getParent());
|
|
Files.write(targetPath, "existing".getBytes());
|
|
|
|
MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes());
|
|
com.music.common.Result<com.music.dto.UploadFileResponse> result = ingestController.upload(file, "album/test.mp3");
|
|
|
|
assertEquals(0, result.getCode());
|
|
assertEquals("skipped", result.getData().getStatus());
|
|
|
|
// Assert content not overwritten
|
|
assertEquals("existing", new String(Files.readAllBytes(targetPath)));
|
|
}
|
|
|
|
@Test
|
|
public void testUploadInvalidPaths() {
|
|
MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes());
|
|
String[] badPaths = { "../test.mp3", "/etc/passwd", "C:\\test.mp3", "album\\test.mp3", "./test.mp3", "album/.", "C:/test.mp3" };
|
|
for (String path : badPaths) {
|
|
BusinessException ex = assertThrows(BusinessException.class, () -> {
|
|
ingestController.upload(file, path);
|
|
});
|
|
assertEquals(400, ex.getCode());
|
|
}
|
|
}
|
|
|
|
@Test
|
|
public void testUploadEmptyFile() {
|
|
MockMultipartFile file = new MockMultipartFile("file", "empty.mp3", "audio/mpeg", new byte[0]);
|
|
BusinessException ex = assertThrows(BusinessException.class, () -> {
|
|
ingestController.upload(file, "album/empty.mp3");
|
|
});
|
|
assertEquals(400, ex.getCode());
|
|
assertTrue(ex.getMessage().contains("文件为空"));
|
|
}
|
|
|
|
@Test
|
|
public void testUploadUnsupportedExtension() {
|
|
MockMultipartFile file = new MockMultipartFile("file", "test.exe", "application/octet-stream", "testdata".getBytes());
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class, () -> {
|
|
ingestController.upload(file, "test.exe");
|
|
});
|
|
assertEquals(400, ex.getCode());
|
|
assertTrue(ex.getMessage().contains("不支持"));
|
|
}
|
|
|
|
@Test
|
|
public void testUploadSymlinkRejected() throws Exception {
|
|
Path targetDir = inputDir.resolve("album");
|
|
Files.createDirectories(targetDir);
|
|
Path realFile = tempDir.resolve("real.txt");
|
|
Files.write(realFile, "test".getBytes());
|
|
Path symlink = targetDir.resolve("link");
|
|
try {
|
|
Files.createSymbolicLink(symlink, realFile);
|
|
} catch (UnsupportedOperationException | IOException e) {
|
|
// Ignore on platforms without symlink support
|
|
return;
|
|
}
|
|
|
|
MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes());
|
|
BusinessException ex = assertThrows(BusinessException.class, () -> {
|
|
ingestController.upload(file, "album/link/test.mp3");
|
|
});
|
|
assertEquals(400, ex.getCode());
|
|
assertTrue(ex.getMessage().contains("符号链接"));
|
|
}
|
|
|
|
@Test
|
|
public void testUploadExtensionFromRelativePath() throws Exception {
|
|
MockMultipartFile file = new MockMultipartFile("file", "test.txt", "audio/mpeg", "testdata".getBytes());
|
|
com.music.common.Result<com.music.dto.UploadFileResponse> result = ingestController.upload(file, "album/test.mp3");
|
|
|
|
assertEquals(0, result.getCode());
|
|
assertEquals("uploaded", result.getData().getStatus());
|
|
}
|
|
|
|
static class BlockingMultipartFile extends MockMultipartFile {
|
|
private final CountDownLatch startLatch;
|
|
private final CountDownLatch endLatch;
|
|
|
|
public BlockingMultipartFile(String name, String originalFilename, String contentType, byte[] content, CountDownLatch startLatch, CountDownLatch endLatch) {
|
|
super(name, originalFilename, contentType, content);
|
|
this.startLatch = startLatch;
|
|
this.endLatch = endLatch;
|
|
}
|
|
|
|
@Override
|
|
public byte[] getBytes() throws IOException {
|
|
block();
|
|
return super.getBytes();
|
|
}
|
|
|
|
@Override
|
|
public java.io.InputStream getInputStream() throws IOException {
|
|
block();
|
|
return super.getInputStream();
|
|
}
|
|
|
|
@Override
|
|
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
|
|
block();
|
|
super.transferTo(dest);
|
|
}
|
|
|
|
@Override
|
|
public void transferTo(Path dest) throws IOException, IllegalStateException {
|
|
block();
|
|
super.transferTo(dest);
|
|
}
|
|
|
|
private void block() {
|
|
startLatch.countDown();
|
|
try {
|
|
endLatch.await();
|
|
} catch (InterruptedException e) {
|
|
Thread.currentThread().interrupt();
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
public void testConcurrencyStartUpload() throws InterruptedException, Exception {
|
|
CountDownLatch startLatch = new CountDownLatch(1);
|
|
CountDownLatch endLatch = new CountDownLatch(1);
|
|
|
|
ExecutorService executor = Executors.newFixedThreadPool(1);
|
|
|
|
BlockingMultipartFile blockingFile = new BlockingMultipartFile(
|
|
"file", "blocking.mp3", "audio/mpeg", "data".getBytes(), startLatch, endLatch);
|
|
|
|
executor.submit(() -> {
|
|
try {
|
|
ingestController.upload(blockingFile, "blocking.mp3");
|
|
} catch (Exception ignored) {
|
|
}
|
|
});
|
|
|
|
assertTrue(startLatch.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
|
|
|
BusinessException ex = assertThrows(BusinessException.class, () -> {
|
|
ingestController.start(new IngestRequest());
|
|
});
|
|
assertEquals(409, ex.getCode());
|
|
|
|
endLatch.countDown();
|
|
|
|
executor.shutdown();
|
|
executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS);
|
|
|
|
com.music.common.Result<?> res = ingestController.start(new IngestRequest());
|
|
assertEquals(0, res.getCode());
|
|
}
|
|
|
|
@Test
|
|
public void testEarlyFailureDoesNotBlockStart() throws Exception {
|
|
// missing config
|
|
when(configService.getInputDir()).thenReturn(null);
|
|
MockMultipartFile file = new MockMultipartFile("file", "test.mp3", "audio/mpeg", "testdata".getBytes());
|
|
assertThrows(BusinessException.class, () -> ingestController.upload(file, "album/test.mp3"));
|
|
when(configService.getInputDir()).thenReturn(inputDir.toString());
|
|
|
|
// empty file
|
|
MockMultipartFile emptyFile = new MockMultipartFile("file", "test.mp3", "audio/mpeg", new byte[0]);
|
|
assertThrows(BusinessException.class, () -> ingestController.upload(emptyFile, "album/test.mp3"));
|
|
|
|
// invalid path
|
|
assertThrows(BusinessException.class, () -> ingestController.upload(file, "../test.mp3"));
|
|
|
|
// symlink rejected
|
|
Path targetDir = inputDir.resolve("album");
|
|
Files.createDirectories(targetDir);
|
|
Path realFile = tempDir.resolve("real.txt");
|
|
Files.write(realFile, "test".getBytes());
|
|
Path symlink = targetDir.resolve("link");
|
|
try {
|
|
Files.createSymbolicLink(symlink, realFile);
|
|
assertThrows(BusinessException.class, () -> ingestController.upload(file, "album/link/test.mp3"));
|
|
} catch (UnsupportedOperationException | IOException ignored) {
|
|
}
|
|
|
|
// skipped existing
|
|
Path targetPath = inputDir.resolve("album/test.mp3");
|
|
Files.createDirectories(targetPath.getParent());
|
|
Files.write(targetPath, "existing".getBytes());
|
|
ingestController.upload(file, "album/test.mp3");
|
|
|
|
// Verify start is not blocked
|
|
com.music.common.Result<?> res = ingestController.start(new IngestRequest());
|
|
assertEquals(0, res.getCode());
|
|
}
|
|
|
|
@Test
|
|
public void testConcurrentUploadNoOverwrite() throws Exception {
|
|
CountDownLatch transferLatch = new CountDownLatch(2);
|
|
CountDownLatch releaseLatch = new CountDownLatch(1);
|
|
|
|
class SyncFile extends MockMultipartFile {
|
|
public SyncFile(String name, String originalFilename, String contentType, byte[] content) {
|
|
super(name, originalFilename, contentType, content);
|
|
}
|
|
@Override
|
|
public void transferTo(java.io.File dest) throws IOException, IllegalStateException {
|
|
super.transferTo(dest);
|
|
transferLatch.countDown();
|
|
try { releaseLatch.await(); } catch (InterruptedException e) {}
|
|
}
|
|
@Override
|
|
public void transferTo(Path dest) throws IOException, IllegalStateException {
|
|
super.transferTo(dest);
|
|
transferLatch.countDown();
|
|
try { releaseLatch.await(); } catch (InterruptedException e) {}
|
|
}
|
|
}
|
|
|
|
SyncFile file1 = new SyncFile("file", "test.mp3", "audio/mpeg", "content_A".getBytes());
|
|
SyncFile file2 = new SyncFile("file", "test.mp3", "audio/mpeg", "content_B".getBytes());
|
|
|
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
|
java.util.concurrent.Future<com.music.common.Result<com.music.dto.UploadFileResponse>> f1 = executor.submit(() -> ingestController.upload(file1, "concurrent/test.mp3"));
|
|
java.util.concurrent.Future<com.music.common.Result<com.music.dto.UploadFileResponse>> f2 = executor.submit(() -> ingestController.upload(file2, "concurrent/test.mp3"));
|
|
|
|
assertTrue(transferLatch.await(5, java.util.concurrent.TimeUnit.SECONDS));
|
|
releaseLatch.countDown();
|
|
|
|
com.music.common.Result<com.music.dto.UploadFileResponse> res1 = f1.get();
|
|
com.music.common.Result<com.music.dto.UploadFileResponse> res2 = f2.get();
|
|
executor.shutdown();
|
|
|
|
boolean r1Uploaded = "uploaded".equals(res1.getData().getStatus());
|
|
boolean r2Uploaded = "uploaded".equals(res2.getData().getStatus());
|
|
assertTrue(r1Uploaded ^ r2Uploaded, "Exactly one upload should succeed");
|
|
|
|
Path targetPath = inputDir.resolve("concurrent/test.mp3");
|
|
String content = new String(Files.readAllBytes(targetPath));
|
|
if (r1Uploaded) assertEquals("content_A", content);
|
|
else assertEquals("content_B", content);
|
|
}
|
|
}
|