package com.yoyuzh.files; import com.yoyuzh.admin.AdminMetricsService; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.storage.FileContentStorage; import com.yoyuzh.files.storage.PreparedUpload; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockMultipartFile; import java.io.ByteArrayInputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Clock; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.zip.ZipInputStream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.AdditionalMatchers.aryEq; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class FileServiceTest { @Mock private StoredFileRepository storedFileRepository; @Mock private FileBlobRepository fileBlobRepository; @Mock private FileContentStorage fileContentStorage; @Mock private FileShareLinkRepository fileShareLinkRepository; @Mock private AdminMetricsService adminMetricsService; private FileService fileService; @BeforeEach void setUp() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); fileService = new FileService( storedFileRepository, fileBlobRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties ); } @Test void shouldStoreUploadedFileViaConfiguredStorage() { User user = createUser(7L); MockMultipartFile multipartFile = new MockMultipartFile( "file", "notes.txt", "text/plain", "hello".getBytes()); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> { FileBlob blob = invocation.getArgument(0); blob.setId(100L); return blob; }); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { StoredFile file = invocation.getArgument(0); file.setId(10L); return file; }); FileMetadataResponse response = fileService.upload(user, "/docs", multipartFile); assertThat(response.id()).isEqualTo(10L); assertThat(response.path()).isEqualTo("/docs"); verify(fileContentStorage).uploadBlob(org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq(multipartFile)); verify(fileBlobRepository).save(org.mockito.ArgumentMatchers.argThat(blob -> blob.getObjectKey() != null && blob.getObjectKey().startsWith("blobs/") && blob.getSize().equals(5L) && "text/plain".equals(blob.getContentType()))); } @Test void shouldInitiateDirectUploadThroughStorage() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); when(fileContentStorage.prepareBlobUpload(eq("/docs"), eq("notes.txt"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("text/plain"), eq(12L))) .thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of("Content-Type", "text/plain"), "blobs/upload-1")); InitiateUploadResponse response = fileService.initiateUpload(user, new InitiateUploadRequest("/docs", "notes.txt", "text/plain", 12L)); assertThat(response.direct()).isTrue(); assertThat(response.uploadUrl()).isEqualTo("https://upload.example.com"); assertThat(response.storageName()).startsWith("blobs/"); verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("notes.txt"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("text/plain"), eq(12L)); } @Test void shouldAllowInitiatingUploadAtFiveHundredMegabytes() { User user = createUser(7L); long uploadSize = 500L * 1024 * 1024; when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.zip")).thenReturn(false); when(fileContentStorage.prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize))) .thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of(), "blobs/upload-2")); InitiateUploadResponse response = fileService.initiateUpload(user, new InitiateUploadRequest("/docs", "movie.zip", "application/zip", uploadSize)); assertThat(response.direct()).isTrue(); verify(fileContentStorage).prepareBlobUpload(eq("/docs"), eq("movie.zip"), org.mockito.ArgumentMatchers.argThat(key -> key != null && key.startsWith("blobs/")), eq("application/zip"), eq(uploadSize)); } @Test void shouldCompleteDirectUploadAndPersistMetadata() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> { FileBlob blob = invocation.getArgument(0); blob.setId(101L); return blob; }); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { StoredFile file = invocation.getArgument(0); file.setId(11L); return file; }); FileMetadataResponse response = fileService.completeUpload(user, new CompleteUploadRequest("/docs", "notes.txt", "blobs/upload-3", "text/plain", 12L)); assertThat(response.id()).isEqualTo(11L); verify(fileContentStorage).completeBlobUpload("blobs/upload-3", "text/plain", 12L); } @Test void shouldDeleteUploadedBlobWhenMetadataSaveFails() { User user = createUser(7L); MockMultipartFile multipartFile = new MockMultipartFile( "file", "notes.txt", "text/plain", "hello".getBytes()); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) .thenReturn(Optional.of(createDirectory(20L, user, "/", "docs"))); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); assertThatThrownBy(() -> fileService.upload(user, "/docs", multipartFile)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("insert failed"); verify(fileContentStorage).deleteBlob(org.mockito.ArgumentMatchers.argThat( key -> key != null && key.startsWith("blobs/"))); } @Test void shouldDeleteCompletedUploadBlobWhenMetadataSaveFails() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) .thenReturn(Optional.of(createDirectory(21L, user, "/", "docs"))); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); assertThatThrownBy(() -> fileService.completeUpload(user, new CompleteUploadRequest("/docs", "notes.txt", "blobs/upload-fail", "text/plain", 12L))) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("insert failed"); verify(fileContentStorage).deleteBlob("blobs/upload-fail"); } @Test void shouldCreateMissingDirectoriesBeforeCompletingNestedUpload() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(Optional.empty()); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(Optional.empty()); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); fileService.completeUpload(user, new CompleteUploadRequest("/projects/site", "logo.png", "blobs/upload-4", "image/png", 12L)); verify(fileContentStorage).ensureDirectory(7L, "/projects"); verify(fileContentStorage).ensureDirectory(7L, "/projects/site"); verify(fileContentStorage).completeBlobUpload("blobs/upload-4", "image/png", 12L); verify(storedFileRepository, times(3)).save(any(StoredFile.class)); } @Test void shouldRenameFileThroughConfiguredStorage() { User user = createUser(7L); StoredFile storedFile = createFile(10L, user, "/docs", "notes.txt"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(storedFile)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed.txt")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); FileMetadataResponse response = fileService.rename(user, 10L, "renamed.txt"); assertThat(response.filename()).isEqualTo("renamed.txt"); verify(fileContentStorage, never()).renameFile(any(), any(), any(), any()); } @Test void shouldRenameDirectoryAndUpdateDescendantPaths() { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "renamed-archive")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile)); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); FileMetadataResponse response = fileService.rename(user, 10L, "renamed-archive"); assertThat(response.filename()).isEqualTo("renamed-archive"); assertThat(childFile.getPath()).isEqualTo("/docs/renamed-archive"); verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any()); } @Test void shouldMoveFileToAnotherDirectory() { User user = createUser(7L); StoredFile file = createFile(10L, user, "/docs", "notes.txt"); StoredFile targetDirectory = createDirectory(11L, user, "/", "下载"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); FileMetadataResponse response = fileService.move(user, 10L, "/下载"); assertThat(response.path()).isEqualTo("/下载"); assertThat(file.getPath()).isEqualTo("/下载"); verify(fileContentStorage, never()).moveFile(any(), any(), any(), any()); } @Test void shouldMoveDirectoryAndUpdateDescendantPaths() { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile targetDirectory = createDirectory(11L, user, "/", "图片"); StoredFile childFile = createFile(12L, user, "/docs/archive", "nested.txt"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile)); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); when(storedFileRepository.saveAll(List.of(childFile))).thenReturn(List.of(childFile)); FileMetadataResponse response = fileService.move(user, 10L, "/图片"); assertThat(response.path()).isEqualTo("/图片/archive"); assertThat(directory.getPath()).isEqualTo("/图片"); assertThat(childFile.getPath()).isEqualTo("/图片/archive"); verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any()); } @Test void shouldRejectMovingDirectoryIntoItsOwnDescendant() { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile docsDirectory = createDirectory(11L, user, "/", "docs"); StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive"); StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) .thenReturn(Optional.of(docsDirectory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive")) .thenReturn(Optional.of(archiveDirectory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs/archive", "nested")) .thenReturn(Optional.of(descendantDirectory)); assertThatThrownBy(() -> fileService.move(user, 10L, "/docs/archive/nested")) .isInstanceOf(BusinessException.class) .hasMessageContaining("不能移动到当前目录或其子目录"); } @Test void shouldCopyFileToAnotherDirectory() { User user = createUser(7L); FileBlob blob = createBlob(50L, "blobs/blob-copy", 5L, "text/plain"); StoredFile file = createFile(10L, user, "/docs", "notes.txt", blob); StoredFile targetDirectory = createDirectory(11L, user, "/", "下载"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(Optional.of(targetDirectory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/下载", "notes.txt")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { StoredFile storedFile = invocation.getArgument(0); if (storedFile.getId() == null) { storedFile.setId(20L); } return storedFile; }); FileMetadataResponse response = fileService.copy(user, 10L, "/下载"); assertThat(response.id()).isEqualTo(20L); assertThat(response.path()).isEqualTo("/下载"); assertThat(file.getBlob()).isSameAs(blob); verify(fileContentStorage, never()).copyFile(any(), any(), any(), any()); } @Test void shouldCopyDirectoryAndDescendants() { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile targetDirectory = createDirectory(11L, user, "/", "图片"); StoredFile childDirectory = createDirectory(12L, user, "/docs/archive", "nested"); FileBlob childBlob = createBlob(51L, "blobs/blob-archive-1", 5L, "text/plain"); FileBlob nestedBlob = createBlob(52L, "blobs/blob-archive-2", 5L, "text/plain"); StoredFile childFile = createFile(13L, user, "/docs/archive", "notes.txt", childBlob); StoredFile nestedFile = createFile(14L, user, "/docs/archive/nested", "todo.txt", nestedBlob); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(Optional.of(targetDirectory)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片", "archive")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")) .thenReturn(List.of(childDirectory, childFile, nestedFile)); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive", "nested")).thenReturn(false); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive", "notes.txt")).thenReturn(false); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/图片/archive/nested", "todo.txt")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { StoredFile storedFile = invocation.getArgument(0); if (storedFile.getId() == null) { storedFile.setId(100L + storedFile.getFilename().length()); } return storedFile; }); FileMetadataResponse response = fileService.copy(user, 10L, "/图片"); assertThat(response.path()).isEqualTo("/图片/archive"); verify(fileContentStorage, never()).copyFile(any(), any(), any(), any()); } @Test void shouldRejectCopyingDirectoryIntoItsOwnDescendant() { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile docsDirectory = createDirectory(11L, user, "/", "docs"); StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive"); StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs")) .thenReturn(Optional.of(docsDirectory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive")) .thenReturn(Optional.of(archiveDirectory)); when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs/archive", "nested")) .thenReturn(Optional.of(descendantDirectory)); assertThatThrownBy(() -> fileService.copy(user, 10L, "/docs/archive/nested")) .isInstanceOf(BusinessException.class) .hasMessageContaining("不能复制到当前目录或其子目录"); } @Test void shouldRejectDeletingOtherUsersFile() { User owner = createUser(1L); User requester = createUser(2L); StoredFile storedFile = createFile(100L, owner, "/docs", "notes.txt"); when(storedFileRepository.findDetailedById(100L)).thenReturn(Optional.of(storedFile)); assertThatThrownBy(() -> fileService.delete(requester, 100L)) .isInstanceOf(BusinessException.class) .hasMessageContaining("没有权限"); } @Test void shouldMoveDeletedDirectoryAndDescendantsIntoRecycleBinGroup() { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile nestedDirectory = createDirectory(12L, user, "/docs/archive", "nested"); FileBlob blob = createBlob(60L, "blobs/blob-delete", 5L, "text/plain"); StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt", blob); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(nestedDirectory, childFile)); fileService.delete(user, 10L); assertThat(directory.getDeletedAt()).isNotNull(); assertThat(directory.isRecycleRoot()).isTrue(); assertThat(directory.getRecycleGroupId()).isNotBlank(); assertThat(directory.getRecycleOriginalPath()).isEqualTo("/docs"); assertThat(directory.getPath()).startsWith("/.recycle/"); assertThat(nestedDirectory.getDeletedAt()).isEqualTo(directory.getDeletedAt()); assertThat(nestedDirectory.isRecycleRoot()).isFalse(); assertThat(nestedDirectory.getRecycleGroupId()).isEqualTo(directory.getRecycleGroupId()); assertThat(nestedDirectory.getRecycleOriginalPath()).isEqualTo("/docs/archive"); assertThat(childFile.getDeletedAt()).isEqualTo(directory.getDeletedAt()); assertThat(childFile.isRecycleRoot()).isFalse(); assertThat(childFile.getRecycleGroupId()).isEqualTo(directory.getRecycleGroupId()); assertThat(childFile.getRecycleOriginalPath()).isEqualTo("/docs/archive"); verify(fileContentStorage, never()).deleteBlob(any()); verify(fileBlobRepository, never()).delete(any()); verify(storedFileRepository, never()).deleteAll(any()); verify(storedFileRepository, never()).delete(any()); } @Test void shouldKeepSharedBlobWhenFileMovesIntoRecycleBin() { User user = createUser(7L); FileBlob blob = createBlob(70L, "blobs/blob-shared", 5L, "text/plain"); StoredFile storedFile = createFile(15L, user, "/docs", "shared.txt", blob); when(storedFileRepository.findDetailedById(15L)).thenReturn(Optional.of(storedFile)); fileService.delete(user, 15L); assertThat(storedFile.getDeletedAt()).isNotNull(); assertThat(storedFile.isRecycleRoot()).isTrue(); verify(fileContentStorage, never()).deleteBlob(any()); verify(fileBlobRepository, never()).delete(any()); verify(storedFileRepository, never()).delete(any()); } @Test void shouldDeleteExpiredRecycleBinBlobWhenLastReferenceIsRemoved() { User user = createUser(7L); FileBlob blob = createBlob(71L, "blobs/blob-last", 5L, "text/plain"); StoredFile storedFile = createFile(16L, user, "/docs", "last.txt", blob); storedFile.setDeletedAt(LocalDateTime.now().minusDays(11)); storedFile.setRecycleRoot(true); storedFile.setRecycleGroupId("recycle-group-1"); storedFile.setRecycleOriginalPath("/docs"); storedFile.setPath("/.recycle/recycle-group-1/docs"); when(storedFileRepository.findByDeletedAtBefore(any(LocalDateTime.class))).thenReturn(List.of(storedFile)); when(storedFileRepository.countByBlobId(71L)).thenReturn(1L); fileService.pruneExpiredRecycleBinItems(); verify(fileContentStorage).deleteBlob("blobs/blob-last"); verify(fileBlobRepository).delete(blob); verify(storedFileRepository).deleteAll(List.of(storedFile)); } @Test void shouldListFilesByPathWithPagination() { User user = createUser(7L); StoredFile file = createFile(100L, user, "/docs", "notes.txt"); when(storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc( 7L, "/docs", PageRequest.of(0, 10))) .thenReturn(new PageImpl<>(List.of(file))); var result = fileService.list(user, "/docs", 0, 10); assertThat(result.items()).hasSize(1); assertThat(result.items().get(0).filename()).isEqualTo("notes.txt"); } @Test void shouldCreateDefaultDirectoriesForUserWorkspace() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(false); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "文档")).thenReturn(false); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); fileService.ensureDefaultDirectories(user); verify(fileContentStorage).ensureDirectory(7L, "/下载"); verify(fileContentStorage).ensureDirectory(7L, "/文档"); verify(fileContentStorage).ensureDirectory(7L, "/图片"); verify(storedFileRepository, times(3)).save(any(StoredFile.class)); } @Test void shouldUseSignedDownloadUrlWhenStorageSupportsDirectDownload() { User user = createUser(7L); StoredFile file = createFile(22L, user, "/docs", "notes.txt"); when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file)); when(fileContentStorage.supportsDirectDownload()).thenReturn(true); when(fileContentStorage.createBlobDownloadUrl("blobs/blob-22", "notes.txt")) .thenReturn("https://download.example.com/file"); DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L); assertThat(response.url()).isEqualTo("https://download.example.com/file"); } @Test void shouldUseDlUrlForPrivateApkWhenConfigured() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); properties.getS3().setPackageDownloadBaseUrl("https://api.yoyuzh.xyz/_dl"); properties.getS3().setPackageDownloadSecret("test-secret"); properties.getS3().setPackageDownloadTtlSeconds(300); fileService = new FileService( storedFileRepository, fileBlobRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties, Clock.fixed(Instant.parse("2026-04-04T04:30:00Z"), ZoneOffset.UTC) ); User user = createUser(7L); StoredFile file = createFile(22L, user, "/apps", "安装包.apk"); file.setContentType("application/vnd.android.package-archive"); when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file)); when(fileContentStorage.supportsDirectDownload()).thenReturn(true); DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L); URI uri = URI.create(response.url()); assertThat(uri.getScheme()).isEqualTo("https"); assertThat(uri.getHost()).isEqualTo("api.yoyuzh.xyz"); assertThat(uri.getPath()).isEqualTo("/_dl/blobs/blob-22"); assertThat(response.url()).contains("expires=1775277300"); assertThat(response.url()).contains("md5=1z0AP88pnPz-TpgnYfIT4A"); assertThat(response.url()).contains("response-content-disposition=attachment%3B%20filename%3D%22download.apk%22%3B%20filename*%3DUTF-8%27%27%E5%AE%89%E8%A3%85%E5%8C%85.apk"); verify(fileContentStorage, never()).createBlobDownloadUrl(any(), any()); } @Test void shouldRedirectPrivateApkDownloadToDlWhenConfigured() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); properties.getS3().setPackageDownloadBaseUrl("https://api.yoyuzh.xyz/_dl"); properties.getS3().setPackageDownloadSecret("test-secret"); properties.getS3().setPackageDownloadTtlSeconds(300); fileService = new FileService( storedFileRepository, fileBlobRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties, Clock.fixed(Instant.parse("2026-04-04T04:30:00Z"), ZoneOffset.UTC) ); User user = createUser(7L); StoredFile file = createFile(22L, user, "/apps", "app-debug.apk"); file.setContentType("application/vnd.android.package-archive"); when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file)); when(fileContentStorage.supportsDirectDownload()).thenReturn(true); ResponseEntity response = fileService.download(user, 22L); assertThat(response.getStatusCode().value()).isEqualTo(302); assertThat(response.getHeaders().getLocation()).isNotNull(); assertThat(response.getHeaders().getLocation().getHost()).isEqualTo("api.yoyuzh.xyz"); assertThat(response.getHeaders().getLocation().getPath()).isEqualTo("/_dl/blobs/blob-22"); assertThat(response.getHeaders().getLocation().getQuery()).contains("expires=1775277300"); assertThat(response.getHeaders().getLocation().getQuery()).contains("md5=1z0AP88pnPz-TpgnYfIT4A"); verify(fileContentStorage, never()).createBlobDownloadUrl(any(), any()); } @Test void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() { User user = createUser(7L); StoredFile file = createFile(22L, user, "/docs", "notes.txt"); when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file)); when(fileContentStorage.supportsDirectDownload()).thenReturn(false); DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L); assertThat(response.url()).isEqualTo("/api/files/download/22"); verify(fileContentStorage, never()).createDownloadUrl(any(), any(), any(), any()); } @Test void shouldDownloadDirectoryAsZipArchive() throws Exception { User user = createUser(7L); StoredFile directory = createDirectory(10L, user, "/docs", "archive"); StoredFile childDirectory = createDirectory(11L, user, "/docs/archive", "nested"); StoredFile childFile = createFile(12L, user, "/docs/archive", "notes.txt"); StoredFile nestedFile = createFile(13L, user, "/docs/archive/nested", "todo.txt"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")) .thenReturn(List.of(childDirectory, childFile, nestedFile)); when(fileContentStorage.readBlob("blobs/blob-12")) .thenReturn("hello".getBytes(StandardCharsets.UTF_8)); when(fileContentStorage.readBlob("blobs/blob-13")) .thenReturn("world".getBytes(StandardCharsets.UTF_8)); var response = fileService.download(user, 10L); assertThat(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)) .contains("archive.zip"); assertThat(response.getHeaders().getContentType()) .isEqualTo(MediaType.parseMediaType("application/zip")); Map entries = new LinkedHashMap<>(); try (ZipInputStream zipInputStream = new ZipInputStream( new ByteArrayInputStream((byte[]) response.getBody()), StandardCharsets.UTF_8)) { var entry = zipInputStream.getNextEntry(); while (entry != null) { entries.put(entry.getName(), entry.isDirectory() ? "" : new String(zipInputStream.readAllBytes(), StandardCharsets.UTF_8)); entry = zipInputStream.getNextEntry(); } } assertThat(entries).containsEntry("archive/", ""); assertThat(entries).containsEntry("archive/nested/", ""); assertThat(entries).containsEntry("archive/notes.txt", "hello"); assertThat(entries).containsEntry("archive/nested/todo.txt", "world"); verify(fileContentStorage).readBlob("blobs/blob-12"); verify(fileContentStorage).readBlob("blobs/blob-13"); } @Test void shouldCreateShareLinkForOwnedFile() { User user = createUser(7L); StoredFile file = createFile(22L, user, "/docs", "notes.txt"); when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file)); when(fileShareLinkRepository.save(any(FileShareLink.class))).thenAnswer(invocation -> { FileShareLink shareLink = invocation.getArgument(0); shareLink.setId(100L); shareLink.setToken("share-token-1"); return shareLink; }); CreateFileShareLinkResponse response = fileService.createShareLink(user, 22L); assertThat(response.token()).isEqualTo("share-token-1"); assertThat(response.filename()).isEqualTo("notes.txt"); verify(fileShareLinkRepository).save(any(FileShareLink.class)); } @Test void shouldImportSharedFileIntoRecipientWorkspace() { User owner = createUser(7L); User recipient = createUser(8L); FileBlob blob = createBlob(80L, "blobs/blob-import", 5L, "text/plain"); StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt", blob); FileShareLink shareLink = new FileShareLink(); shareLink.setId(100L); shareLink.setToken("share-token-1"); shareLink.setOwner(owner); shareLink.setFile(sourceFile); shareLink.setCreatedAt(LocalDateTime.now()); when(fileShareLinkRepository.findByToken("share-token-1")).thenReturn(Optional.of(shareLink)); when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/下载", "notes.txt")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> { StoredFile file = invocation.getArgument(0); file.setId(200L); return file; }); FileMetadataResponse response = fileService.importSharedFile(recipient, "share-token-1", "/下载"); assertThat(response.id()).isEqualTo(200L); assertThat(response.path()).isEqualTo("/下载"); assertThat(response.filename()).isEqualTo("notes.txt"); verify(fileContentStorage, never()).storeImportedFile(any(), any(), any(), any(), any()); verify(fileContentStorage, never()).readFile(any(), any(), any()); } @Test void shouldDeleteImportedBlobWhenMetadataSaveFails() { User recipient = createUser(8L); byte[] content = "hello".getBytes(StandardCharsets.UTF_8); when(storedFileRepository.existsByUserIdAndPathAndFilename(8L, "/下载", "notes.txt")).thenReturn(false); when(storedFileRepository.findByUserIdAndPathAndFilename(8L, "/", "下载")) .thenReturn(Optional.of(createDirectory(22L, recipient, "/", "下载"))); when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> invocation.getArgument(0)); doThrow(new IllegalStateException("insert failed")).when(storedFileRepository).save(any(StoredFile.class)); assertThatThrownBy(() -> fileService.importExternalFile(recipient, "/下载", "notes.txt", "text/plain", content.length, content)) .isInstanceOf(IllegalStateException.class) .hasMessageContaining("insert failed"); verify(fileContentStorage).deleteBlob(org.mockito.ArgumentMatchers.argThat( key -> key != null && key.startsWith("blobs/"))); } private User createUser(Long id) { User user = new User(); user.setId(id); user.setUsername("user-" + id); user.setEmail("user-" + id + "@example.com"); user.setPasswordHash("encoded"); user.setCreatedAt(LocalDateTime.now()); return user; } private FileBlob createBlob(Long id, String objectKey, Long size, String contentType) { FileBlob blob = new FileBlob(); blob.setId(id); blob.setObjectKey(objectKey); blob.setSize(size); blob.setContentType(contentType); blob.setCreatedAt(LocalDateTime.now()); return blob; } private StoredFile createFile(Long id, User user, String path, String filename) { return createFile(id, user, path, filename, createBlob(id, "blobs/blob-" + id, 5L, "text/plain")); } private StoredFile createFile(Long id, User user, String path, String filename, FileBlob blob) { StoredFile file = new StoredFile(); file.setId(id); file.setUser(user); file.setFilename(filename); file.setPath(path); file.setSize(5L); file.setDirectory(false); file.setContentType("text/plain"); file.setBlob(blob); file.setCreatedAt(LocalDateTime.now()); return file; } private StoredFile createDirectory(Long id, User user, String path, String filename) { StoredFile directory = createFile(id, user, path, filename); directory.setDirectory(true); directory.setContentType("directory"); directory.setSize(0L); directory.setBlob(null); return directory; } }