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 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 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 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 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 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 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 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> f1 = executor.submit(() -> ingestController.upload(file1, "concurrent/test.mp3")); java.util.concurrent.Future> f2 = executor.submit(() -> ingestController.upload(file2, "concurrent/test.mp3")); assertTrue(transferLatch.await(5, java.util.concurrent.TimeUnit.SECONDS)); releaseLatch.countDown(); com.music.common.Result res1 = f1.get(); com.music.common.Result 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); } }