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 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.http.HttpHeaders; import org.springframework.http.ResponseEntity; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * Covers edge cases not addressed in FileServiceTest. */ @ExtendWith(MockitoExtension.class) class FileServiceEdgeCaseTest { @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 ); } // --- normalizeDirectoryPath edge cases --- @Test void shouldRejectPathContainingDotDot() { User user = createUser(1L); assertThatThrownBy(() -> fileService.mkdir(user, "/docs/../secret")) .isInstanceOf(BusinessException.class) .hasMessageContaining("路径不合法"); } @Test void shouldNormalizeBackslashesInPath() { User user = createUser(1L); when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(inv -> { StoredFile f = inv.getArgument(0); f.setId(10L); return f; }); // backslash should be treated as path separator and normalized FileMetadataResponse response = fileService.mkdir(user, "\\docs"); assertThat(response.path()).isEqualTo("/docs"); } @Test void shouldNormalizeTrailingSlashInPath() { User user = createUser(1L); when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(inv -> { StoredFile f = inv.getArgument(0); f.setId(10L); return f; }); FileMetadataResponse response = fileService.mkdir(user, "/docs/"); assertThat(response.path()).isEqualTo("/docs"); } @Test void shouldNormalizeDoubleSlashInPath() { User user = createUser(1L); when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(false); when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(inv -> { StoredFile f = inv.getArgument(0); f.setId(10L); return f; }); FileMetadataResponse response = fileService.mkdir(user, "//docs"); assertThat(response.path()).isEqualTo("/docs"); } // --- mkdir edge cases --- @Test void shouldRejectCreatingRootDirectory() { User user = createUser(1L); assertThatThrownBy(() -> fileService.mkdir(user, "/")) .isInstanceOf(BusinessException.class) .hasMessageContaining("根目录无需创建"); } @Test void shouldRejectCreatingAlreadyExistingDirectory() { User user = createUser(1L); when(storedFileRepository.existsByUserIdAndPathAndFilename(1L, "/", "docs")).thenReturn(true); assertThatThrownBy(() -> fileService.mkdir(user, "/docs")) .isInstanceOf(BusinessException.class) .hasMessageContaining("目录已存在"); } // --- download redirect for direct download --- @Test void shouldReturn302RedirectWhenStorageSupportsDirectDownloadForFile() { User user = createUser(1L); StoredFile file = createFile(10L, user, "/docs", "notes.txt"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file)); when(fileContentStorage.supportsDirectDownload()).thenReturn(true); when(fileContentStorage.createBlobDownloadUrl("blobs/blob-10", "notes.txt")) .thenReturn("https://cdn.example.com/notes.txt"); ResponseEntity response = fileService.download(user, 10L); assertThat(response.getStatusCodeValue()).isEqualTo(302); assertThat(response.getHeaders().getFirst(HttpHeaders.LOCATION)) .isEqualTo("https://cdn.example.com/notes.txt"); } // --- createShareLink edge cases --- @Test void shouldRejectCreatingShareLinkForDirectory() { User user = createUser(1L); StoredFile directory = createDirectory(10L, user, "/", "docs"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); assertThatThrownBy(() -> fileService.createShareLink(user, 10L)) .isInstanceOf(BusinessException.class) .hasMessageContaining("目录暂不支持分享链接"); } // --- getDownloadUrl edge cases --- @Test void shouldRejectDownloadUrlForDirectory() { User user = createUser(1L); StoredFile directory = createDirectory(10L, user, "/", "docs"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory)); assertThatThrownBy(() -> fileService.getDownloadUrl(user, 10L)) .isInstanceOf(BusinessException.class) .hasMessageContaining("目录不支持下载"); } // --- upload size limit --- @Test void shouldRejectUploadExceedingMaxFileSize() { User user = createUser(1L); long oversizedFile = 500L * 1024 * 1024 + 1; assertThatThrownBy(() -> fileService.initiateUpload(user, new InitiateUploadRequest("/docs", "big.zip", "application/zip", oversizedFile))) .isInstanceOf(BusinessException.class) .hasMessageContaining("文件大小超出限制"); } @Test void shouldRejectUploadExceedingUserMaxUploadSizeLimit() { User user = createUser(1L); user.setMaxUploadSizeBytes(1024L); assertThatThrownBy(() -> fileService.initiateUpload(user, new InitiateUploadRequest("/docs", "large.bin", "application/octet-stream", 1025L))) .isInstanceOf(BusinessException.class) .hasMessageContaining("文件大小超出限制"); } @Test void shouldRejectUploadWhenUserStorageQuotaInsufficient() { User user = createUser(1L); user.setStorageQuotaBytes(1024L); when(storedFileRepository.sumFileSizeByUserId(1L)).thenReturn(900L); assertThatThrownBy(() -> fileService.initiateUpload(user, new InitiateUploadRequest("/docs", "quota.bin", "application/octet-stream", 200L))) .isInstanceOf(BusinessException.class) .hasMessageContaining("存储空间不足"); } // --- rename no-op when name unchanged --- @Test void shouldReturnUnchangedFileWhenRenameToSameName() { User user = createUser(1L); StoredFile file = createFile(10L, user, "/docs", "notes.txt"); when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file)); FileMetadataResponse response = fileService.rename(user, 10L, "notes.txt"); assertThat(response.filename()).isEqualTo("notes.txt"); verify(storedFileRepository, org.mockito.Mockito.never()).save(any()); } // --- helpers --- 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 StoredFile createFile(Long id, User user, String path, String filename) { 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(createBlob(id, "blobs/blob-" + id, 5L, "text/plain")); file.setCreatedAt(LocalDateTime.now()); return file; } private StoredFile createDirectory(Long id, User user, String path, String filename) { StoredFile dir = createFile(id, user, path, filename); dir.setDirectory(true); dir.setContentType("directory"); dir.setSize(0L); dir.setBlob(null); return dir; } 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; } }