Fix Android WebView API access and mobile shell layout

This commit is contained in:
yoyuzh
2026-04-03 14:37:21 +08:00
parent f02ff9342f
commit 56f2a9fe0d
121 changed files with 4751 additions and 700 deletions

View File

@@ -4,6 +4,8 @@ import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.admin.AdminMetricsStateRepository;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.files.FileBlob;
import com.yoyuzh.files.FileBlobRepository;
import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository;
import com.yoyuzh.transfer.OfflineTransferSessionRepository;
@@ -17,6 +19,7 @@ import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.time.LocalDate;
import java.time.LocalTime;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
@@ -56,9 +59,13 @@ class AdminControllerIntegrationTest {
@Autowired
private StoredFileRepository storedFileRepository;
@Autowired
private FileBlobRepository fileBlobRepository;
@Autowired
private OfflineTransferSessionRepository offlineTransferSessionRepository;
@Autowired
private AdminMetricsStateRepository adminMetricsStateRepository;
@Autowired
private AdminMetricsService adminMetricsService;
private User portalUser;
private User secondaryUser;
@@ -69,6 +76,7 @@ class AdminControllerIntegrationTest {
void setUp() {
offlineTransferSessionRepository.deleteAll();
storedFileRepository.deleteAll();
fileBlobRepository.deleteAll();
userRepository.deleteAll();
adminMetricsStateRepository.deleteAll();
@@ -88,33 +96,47 @@ class AdminControllerIntegrationTest {
secondaryUser.setCreatedAt(LocalDateTime.now().minusDays(1));
secondaryUser = userRepository.save(secondaryUser);
FileBlob reportBlob = createBlob("blobs/admin-report", "application/pdf", 1024L);
storedFile = new StoredFile();
storedFile.setUser(portalUser);
storedFile.setFilename("report.pdf");
storedFile.setPath("/");
storedFile.setStorageName("report.pdf");
storedFile.setContentType("application/pdf");
storedFile.setSize(1024L);
storedFile.setDirectory(false);
storedFile.setBlob(reportBlob);
storedFile.setCreatedAt(LocalDateTime.now());
storedFile = storedFileRepository.save(storedFile);
FileBlob notesBlob = createBlob("blobs/admin-notes", "text/plain", 256L);
secondaryFile = new StoredFile();
secondaryFile.setUser(secondaryUser);
secondaryFile.setFilename("notes.txt");
secondaryFile.setPath("/docs");
secondaryFile.setStorageName("notes.txt");
secondaryFile.setContentType("text/plain");
secondaryFile.setSize(256L);
secondaryFile.setDirectory(false);
secondaryFile.setBlob(notesBlob);
secondaryFile.setCreatedAt(LocalDateTime.now().minusHours(2));
secondaryFile = storedFileRepository.save(secondaryFile);
}
private FileBlob createBlob(String objectKey, String contentType, long size) {
FileBlob blob = new FileBlob();
blob.setObjectKey(objectKey);
blob.setContentType(contentType);
blob.setSize(size);
blob.setCreatedAt(LocalDateTime.now());
return fileBlobRepository.save(blob);
}
@Test
@WithMockUser(username = "admin")
void shouldAllowConfiguredAdminToListUsersAndSummary() throws Exception {
int currentHour = LocalTime.now().getHour();
LocalDate today = LocalDate.now();
adminMetricsService.recordUserOnline(portalUser.getId(), portalUser.getUsername());
adminMetricsService.recordUserOnline(secondaryUser.getId(), secondaryUser.getUsername());
mockMvc.perform(get("/api/admin/users?page=0&size=10"))
.andExpect(status().isOk())
@@ -134,13 +156,18 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.data.totalStorageBytes").value(1280L))
.andExpect(jsonPath("$.data.downloadTrafficBytes").value(0L))
.andExpect(jsonPath("$.data.requestCount", greaterThanOrEqualTo(1)))
.andExpect(jsonPath("$.data.requestTimeline.length()").value(24))
.andExpect(jsonPath("$.data.requestTimeline.length()").value(currentHour + 1))
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].hour").value(currentHour))
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].label").value(String.format("%02d:00", currentHour)))
.andExpect(jsonPath("$.data.requestTimeline[" + currentHour + "].requestCount", greaterThanOrEqualTo(1)))
.andExpect(jsonPath("$.data.transferUsageBytes").value(0L))
.andExpect(jsonPath("$.data.offlineTransferStorageBytes").value(0L))
.andExpect(jsonPath("$.data.offlineTransferStorageLimitBytes").isNumber())
.andExpect(jsonPath("$.data.dailyActiveUsers.length()").value(7))
.andExpect(jsonPath("$.data.dailyActiveUsers[6].metricDate").value(today.toString()))
.andExpect(jsonPath("$.data.dailyActiveUsers[6].userCount").value(2))
.andExpect(jsonPath("$.data.dailyActiveUsers[6].usernames[0]").value("alice"))
.andExpect(jsonPath("$.data.dailyActiveUsers[6].usernames[1]").value("bob"))
.andExpect(jsonPath("$.data.inviteCode").isNotEmpty());
}

View File

@@ -12,6 +12,8 @@ import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -22,12 +24,18 @@ class AdminMetricsServiceTest {
private AdminMetricsStateRepository adminMetricsStateRepository;
@Mock
private AdminRequestTimelinePointRepository adminRequestTimelinePointRepository;
@Mock
private AdminDailyActiveUserRepository adminDailyActiveUserRepository;
private AdminMetricsService adminMetricsService;
@BeforeEach
void setUp() {
adminMetricsService = new AdminMetricsService(adminMetricsStateRepository, adminRequestTimelinePointRepository);
adminMetricsService = new AdminMetricsService(
adminMetricsStateRepository,
adminRequestTimelinePointRepository,
adminDailyActiveUserRepository
);
}
@Test
@@ -41,15 +49,21 @@ class AdminMetricsServiceTest {
when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(state));
when(adminMetricsStateRepository.save(any(AdminMetricsState.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(LocalDate.now())).thenReturn(java.util.List.of());
when(adminDailyActiveUserRepository.findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(LocalDate.now().minusDays(6), LocalDate.now()))
.thenReturn(java.util.List.of());
AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot();
assertThat(snapshot.requestCount()).isZero();
assertThat(state.getRequestCount()).isZero();
assertThat(state.getRequestCountDate()).isEqualTo(LocalDate.now());
assertThat(snapshot.requestTimeline()).hasSize(24);
assertThat(snapshot.requestTimeline()).hasSize(LocalTime.now().getHour() + 1);
assertThat(snapshot.requestTimeline().get(0)).isEqualTo(new AdminRequestTimelinePoint(0, "00:00", 0L));
assertThat(snapshot.dailyActiveUsers()).hasSize(7);
assertThat(snapshot.dailyActiveUsers().get(6).metricDate()).isEqualTo(LocalDate.now());
assertThat(snapshot.dailyActiveUsers().get(6).userCount()).isZero();
verify(adminMetricsStateRepository).save(state);
verify(adminDailyActiveUserRepository).deleteAllByMetricDateBefore(LocalDate.now().minusDays(6));
}
@Test
@@ -76,4 +90,46 @@ class AdminMetricsServiceTest {
verify(adminMetricsStateRepository).save(state);
verify(adminRequestTimelinePointRepository).save(any(AdminRequestTimelinePointEntity.class));
}
@Test
void shouldRecordUniqueDailyActiveUserAndBuildSevenDayHistory() {
LocalDate today = LocalDate.now();
AdminDailyActiveUserEntity existing = new AdminDailyActiveUserEntity();
existing.setMetricDate(today);
existing.setUserId(7L);
existing.setUsername("alice");
AdminDailyActiveUserEntity yesterday = new AdminDailyActiveUserEntity();
yesterday.setMetricDate(today.minusDays(1));
yesterday.setUserId(8L);
yesterday.setUsername("bob");
when(adminDailyActiveUserRepository.findByMetricDateAndUserIdForUpdate(today, 7L)).thenReturn(Optional.of(existing));
when(adminDailyActiveUserRepository.findAllByMetricDateBetweenOrderByMetricDateAscUsernameAsc(today.minusDays(6), today))
.thenReturn(java.util.List.of(yesterday, existing));
when(adminMetricsStateRepository.findById(1L)).thenReturn(Optional.of(createCurrentState(today)));
when(adminRequestTimelinePointRepository.findAllByMetricDateOrderByHourAsc(today)).thenReturn(java.util.List.of());
adminMetricsService.recordUserOnline(7L, "alice");
AdminMetricsSnapshot snapshot = adminMetricsService.getSnapshot();
assertThat(snapshot.dailyActiveUsers()).hasSize(7);
assertThat(snapshot.dailyActiveUsers().get(5).metricDate()).isEqualTo(today.minusDays(1));
assertThat(snapshot.dailyActiveUsers().get(5).userCount()).isEqualTo(1L);
assertThat(snapshot.dailyActiveUsers().get(5).usernames()).containsExactly("bob");
assertThat(snapshot.dailyActiveUsers().get(6).metricDate()).isEqualTo(today);
assertThat(snapshot.dailyActiveUsers().get(6).userCount()).isEqualTo(1L);
assertThat(snapshot.dailyActiveUsers().get(6).usernames()).containsExactly("alice");
verify(adminDailyActiveUserRepository, never()).save(any(AdminDailyActiveUserEntity.class));
verify(adminDailyActiveUserRepository, times(2)).deleteAllByMetricDateBefore(today.minusDays(6));
}
private AdminMetricsState createCurrentState(LocalDate metricDate) {
AdminMetricsState state = new AdminMetricsState();
state.setId(1L);
state.setRequestCount(0L);
state.setRequestCountDate(metricDate);
state.setOfflineTransferStorageLimitBytes(20L * 1024 * 1024 * 1024);
return state;
}
}

View File

@@ -8,6 +8,7 @@ import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.auth.UserRole;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.FileBlobRepository;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository;
@@ -42,6 +43,8 @@ class AdminServiceTest {
@Mock
private StoredFileRepository storedFileRepository;
@Mock
private FileBlobRepository fileBlobRepository;
@Mock
private FileService fileService;
@Mock
private PasswordEncoder passwordEncoder;
@@ -59,7 +62,7 @@ class AdminServiceTest {
@BeforeEach
void setUp() {
adminService = new AdminService(
userRepository, storedFileRepository, fileService,
userRepository, storedFileRepository, fileBlobRepository, fileService,
passwordEncoder, refreshTokenService, registrationInviteService,
offlineTransferSessionRepository, adminMetricsService);
}
@@ -70,12 +73,16 @@ class AdminServiceTest {
void shouldReturnSummaryWithCountsAndInviteCode() {
when(userRepository.count()).thenReturn(5L);
when(storedFileRepository.count()).thenReturn(42L);
when(storedFileRepository.sumAllFileSize()).thenReturn(8192L);
when(fileBlobRepository.sumAllBlobSize()).thenReturn(8192L);
when(adminMetricsService.getSnapshot()).thenReturn(new AdminMetricsSnapshot(
0L,
0L,
0L,
20L * 1024 * 1024 * 1024,
List.of(
new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "昨天", 1L, List.of("alice")),
new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "今天", 2L, List.of("alice", "bob"))
),
List.of(
new AdminRequestTimelinePoint(0, "00:00", 0L),
new AdminRequestTimelinePoint(1, "01:00", 3L)
@@ -94,6 +101,10 @@ class AdminServiceTest {
assertThat(summary.transferUsageBytes()).isZero();
assertThat(summary.offlineTransferStorageBytes()).isZero();
assertThat(summary.offlineTransferStorageLimitBytes()).isGreaterThan(0L);
assertThat(summary.dailyActiveUsers()).containsExactly(
new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate().minusDays(1), "昨天", 1L, List.of("alice")),
new AdminDailyActiveUserSummary(LocalDateTime.now().toLocalDate(), "今天", 2L, List.of("alice", "bob"))
);
assertThat(summary.requestTimeline()).containsExactly(
new AdminRequestTimelinePoint(0, "00:00", 0L),
new AdminRequestTimelinePoint(1, "01:00", 3L)

View File

@@ -1,17 +1,14 @@
package com.yoyuzh.auth;
import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.files.FileService;
import com.yoyuzh.files.StoredFileRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@@ -39,15 +36,9 @@ class DevBootstrapDataInitializerTest {
@Mock
private StoredFileRepository storedFileRepository;
@Mock
private FileStorageProperties fileStorageProperties;
@InjectMocks
private DevBootstrapDataInitializer initializer;
@TempDir
Path tempDir;
@Test
void shouldCreateInitialDevUsersWhenMissing() throws Exception {
when(userRepository.findByUsername("portal-demo")).thenReturn(Optional.empty());
@@ -57,7 +48,6 @@ class DevBootstrapDataInitializerTest {
when(passwordEncoder.encode("study123456")).thenReturn("encoded-study-password");
when(passwordEncoder.encode("design123456")).thenReturn("encoded-design-password");
when(storedFileRepository.existsByUserIdAndPathAndFilename(anyLong(), anyString(), anyString())).thenReturn(false);
when(fileStorageProperties.getRootDir()).thenReturn(tempDir.toString());
List<User> savedUsers = new ArrayList<>();
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
@@ -71,9 +61,9 @@ class DevBootstrapDataInitializerTest {
verify(userRepository, times(3)).save(any(User.class));
verify(fileService, times(3)).ensureDefaultDirectories(any(User.class));
verify(fileService, times(9)).importExternalFile(any(User.class), anyString(), anyString(), anyString(), anyLong(), any());
org.assertj.core.api.Assertions.assertThat(savedUsers)
.extracting(User::getUsername)
.containsExactly("portal-demo", "portal-study", "portal-design");
verify(storedFileRepository, times(9)).save(any());
}
}

View File

@@ -1,5 +1,6 @@
package com.yoyuzh.config;
import com.yoyuzh.admin.AdminMetricsService;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.JwtTokenProvider;
import com.yoyuzh.auth.User;
@@ -33,13 +34,15 @@ class JwtAuthenticationFilterTest {
@Mock
private CustomUserDetailsService userDetailsService;
@Mock
private AdminMetricsService adminMetricsService;
@Mock
private FilterChain filterChain;
private JwtAuthenticationFilter filter;
@BeforeEach
void setUp() {
filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService);
filter = new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService, adminMetricsService);
SecurityContextHolder.clearContext();
}
@@ -159,6 +162,7 @@ class JwtAuthenticationFilterTest {
verify(filterChain).doFilter(request, response);
assertThat(SecurityContextHolder.getContext().getAuthentication()).isNotNull();
assertThat(SecurityContextHolder.getContext().getAuthentication().getName()).isEqualTo("alice");
verify(adminMetricsService).recordUserOnline(1L, "alice");
}
private User createDomainUser(String username, String sessionId) {

View File

@@ -15,7 +15,15 @@ class SecurityConfigTest {
CorsProperties corsProperties = new CorsProperties();
assertThat(corsProperties.getAllowedOrigins())
.contains("https://yoyuzh.xyz", "https://www.yoyuzh.xyz");
.contains(
"http://localhost",
"https://localhost",
"http://127.0.0.1",
"https://127.0.0.1",
"capacitor://localhost",
"https://yoyuzh.xyz",
"https://www.yoyuzh.xyz"
);
}
@Test

View File

@@ -0,0 +1,89 @@
package com.yoyuzh.files;
import com.yoyuzh.auth.User;
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 java.time.LocalDateTime;
import java.util.Optional;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FileBlobBackfillServiceTest {
@Mock
private StoredFileRepository storedFileRepository;
@Mock
private FileBlobRepository fileBlobRepository;
@Mock
private FileContentStorage fileContentStorage;
private FileBlobBackfillService backfillService;
@BeforeEach
void setUp() {
backfillService = new FileBlobBackfillService(storedFileRepository, fileBlobRepository, fileContentStorage);
}
@Test
void shouldCreateMissingBlobFromLegacyStorageName() {
StoredFile legacyFile = createLegacyFile(10L, 7L, "/docs", "notes.txt", "notes.txt");
when(storedFileRepository.findAllByDirectoryFalseAndBlobIsNull()).thenReturn(java.util.List.of(legacyFile));
when(fileContentStorage.resolveLegacyFileObjectKey(7L, "/docs", "notes.txt")).thenReturn("users/7/docs/notes.txt");
when(fileBlobRepository.findByObjectKey("users/7/docs/notes.txt")).thenReturn(Optional.empty());
when(fileBlobRepository.save(any(FileBlob.class))).thenAnswer(invocation -> {
FileBlob blob = invocation.getArgument(0);
blob.setId(100L);
return blob;
});
backfillService.backfillMissingBlobs();
verify(fileBlobRepository).save(any(FileBlob.class));
verify(storedFileRepository).save(legacyFile);
}
@Test
void shouldReuseExistingBlobWhenObjectKeyAlreadyBackfilled() {
StoredFile legacyFile = createLegacyFile(11L, 8L, "/docs", "report.pdf", "report.pdf");
FileBlob existingBlob = new FileBlob();
existingBlob.setId(101L);
existingBlob.setObjectKey("users/8/docs/report.pdf");
existingBlob.setContentType("application/pdf");
existingBlob.setSize(5L);
when(storedFileRepository.findAllByDirectoryFalseAndBlobIsNull()).thenReturn(java.util.List.of(legacyFile));
when(fileContentStorage.resolveLegacyFileObjectKey(8L, "/docs", "report.pdf")).thenReturn("users/8/docs/report.pdf");
when(fileBlobRepository.findByObjectKey("users/8/docs/report.pdf")).thenReturn(Optional.of(existingBlob));
backfillService.backfillMissingBlobs();
verify(fileBlobRepository, never()).save(any(FileBlob.class));
verify(storedFileRepository).save(legacyFile);
}
private StoredFile createLegacyFile(Long id, Long userId, String path, String filename, String legacyStorageName) {
User user = new User();
user.setId(userId);
user.setUsername("user-" + userId);
StoredFile file = new StoredFile();
file.setId(id);
file.setUser(user);
file.setPath(path);
file.setFilename(filename);
file.setLegacyStorageName(legacyStorageName);
file.setContentType("application/pdf");
file.setSize(5L);
file.setDirectory(false);
file.setCreatedAt(LocalDateTime.now());
return file;
}
}

View File

@@ -32,6 +32,8 @@ class FileServiceEdgeCaseTest {
@Mock
private StoredFileRepository storedFileRepository;
@Mock
private FileBlobRepository fileBlobRepository;
@Mock
private FileContentStorage fileContentStorage;
@Mock
private FileShareLinkRepository fileShareLinkRepository;
@@ -44,7 +46,14 @@ class FileServiceEdgeCaseTest {
void setUp() {
FileStorageProperties properties = new FileStorageProperties();
properties.setMaxFileSize(500L * 1024 * 1024);
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
fileService = new FileService(
storedFileRepository,
fileBlobRepository,
fileContentStorage,
fileShareLinkRepository,
adminMetricsService,
properties
);
}
// --- normalizeDirectoryPath edge cases ---
@@ -131,9 +140,9 @@ class FileServiceEdgeCaseTest {
void shouldReturn302RedirectWhenStorageSupportsDirectDownloadForFile() {
User user = createUser(1L);
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file));
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file));
when(fileContentStorage.supportsDirectDownload()).thenReturn(true);
when(fileContentStorage.createDownloadUrl(1L, "/docs", "notes.txt", "notes.txt"))
when(fileContentStorage.createBlobDownloadUrl("blobs/blob-10", "notes.txt"))
.thenReturn("https://cdn.example.com/notes.txt");
ResponseEntity<?> response = fileService.download(user, 10L);
@@ -149,7 +158,7 @@ class FileServiceEdgeCaseTest {
void shouldRejectCreatingShareLinkForDirectory() {
User user = createUser(1L);
StoredFile directory = createDirectory(10L, user, "/", "docs");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
assertThatThrownBy(() -> fileService.createShareLink(user, 10L))
.isInstanceOf(BusinessException.class)
@@ -162,7 +171,7 @@ class FileServiceEdgeCaseTest {
void shouldRejectDownloadUrlForDirectory() {
User user = createUser(1L);
StoredFile directory = createDirectory(10L, user, "/", "docs");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
assertThatThrownBy(() -> fileService.getDownloadUrl(user, 10L))
.isInstanceOf(BusinessException.class)
@@ -211,7 +220,7 @@ class FileServiceEdgeCaseTest {
void shouldReturnUnchangedFileWhenRenameToSameName() {
User user = createUser(1L);
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file));
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file));
FileMetadataResponse response = fileService.rename(user, 10L, "notes.txt");
@@ -237,9 +246,10 @@ class FileServiceEdgeCaseTest {
file.setUser(user);
file.setFilename(filename);
file.setPath(path);
file.setStorageName(filename);
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;
}
@@ -249,6 +259,17 @@ class FileServiceEdgeCaseTest {
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;
}
}

View File

@@ -31,6 +31,7 @@ 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;
@@ -42,6 +43,9 @@ class FileServiceTest {
@Mock
private StoredFileRepository storedFileRepository;
@Mock
private FileBlobRepository fileBlobRepository;
@Mock
private FileContentStorage fileContentStorage;
@@ -56,7 +60,14 @@ class FileServiceTest {
void setUp() {
FileStorageProperties properties = new FileStorageProperties();
properties.setMaxFileSize(500L * 1024 * 1024);
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, adminMetricsService, properties);
fileService = new FileService(
storedFileRepository,
fileBlobRepository,
fileContentStorage,
fileShareLinkRepository,
adminMetricsService,
properties
);
}
@Test
@@ -65,6 +76,11 @@ class FileServiceTest {
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);
@@ -75,22 +91,28 @@ class FileServiceTest {
assertThat(response.id()).isEqualTo(10L);
assertThat(response.path()).isEqualTo("/docs");
verify(fileContentStorage).upload(7L, "/docs", "notes.txt", multipartFile);
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.prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L))
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of("Content-Type", "text/plain"), "notes.txt"));
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");
verify(fileContentStorage).prepareUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
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
@@ -98,20 +120,25 @@ class FileServiceTest {
User user = createUser(7L);
long uploadSize = 500L * 1024 * 1024;
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.zip")).thenReturn(false);
when(fileContentStorage.prepareUpload(7L, "/docs", "movie.zip", "application/zip", uploadSize))
.thenReturn(new PreparedUpload(true, "https://upload.example.com", "PUT", Map.of(), "movie.zip"));
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).prepareUpload(7L, "/docs", "movie.zip", "application/zip", uploadSize);
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);
@@ -119,10 +146,44 @@ class FileServiceTest {
});
FileMetadataResponse response = fileService.completeUpload(user,
new CompleteUploadRequest("/docs", "notes.txt", "notes.txt", "text/plain", 12L));
new CompleteUploadRequest("/docs", "notes.txt", "blobs/upload-3", "text/plain", 12L));
assertThat(response.id()).isEqualTo(11L);
verify(fileContentStorage).completeUpload(7L, "/docs", "notes.txt", "text/plain", 12L);
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.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true);
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.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true);
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
@@ -131,14 +192,15 @@ class FileServiceTest {
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects/site", "logo.png")).thenReturn(false);
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "projects")).thenReturn(false);
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/projects", "site")).thenReturn(false);
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", "logo.png", "image/png", 12L));
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).completeUpload(7L, "/projects/site", "logo.png", "image/png", 12L);
verify(fileContentStorage).completeBlobUpload("blobs/upload-4", "image/png", 12L);
verify(storedFileRepository, times(3)).save(any(StoredFile.class));
}
@@ -146,14 +208,14 @@ class FileServiceTest {
void shouldRenameFileThroughConfiguredStorage() {
User user = createUser(7L);
StoredFile storedFile = createFile(10L, user, "/docs", "notes.txt");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(storedFile));
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).renameFile(7L, "/docs", "notes.txt", "renamed.txt");
verify(fileContentStorage, never()).renameFile(any(), any(), any(), any());
}
@Test
@@ -162,7 +224,7 @@ class FileServiceTest {
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
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));
@@ -171,7 +233,7 @@ class FileServiceTest {
assertThat(response.filename()).isEqualTo("renamed-archive");
assertThat(childFile.getPath()).isEqualTo("/docs/renamed-archive");
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/docs/renamed-archive", List.of(childFile));
verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any());
}
@Test
@@ -179,7 +241,7 @@ class FileServiceTest {
User user = createUser(7L);
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
StoredFile targetDirectory = createDirectory(11L, user, "/", "下载");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(file));
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));
@@ -188,7 +250,7 @@ class FileServiceTest {
assertThat(response.path()).isEqualTo("/下载");
assertThat(file.getPath()).isEqualTo("/下载");
verify(fileContentStorage).moveFile(7L, "/docs", "/下载", "notes.txt");
verify(fileContentStorage, never()).moveFile(any(), any(), any(), any());
}
@Test
@@ -197,7 +259,7 @@ class FileServiceTest {
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
StoredFile targetDirectory = createDirectory(11L, user, "/", "图片");
StoredFile childFile = createFile(12L, user, "/docs/archive", "nested.txt");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
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));
@@ -209,7 +271,7 @@ class FileServiceTest {
assertThat(response.path()).isEqualTo("/图片/archive");
assertThat(directory.getPath()).isEqualTo("/图片");
assertThat(childFile.getPath()).isEqualTo("/图片/archive");
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/图片/archive", List.of(childFile));
verify(fileContentStorage, never()).renameDirectory(any(), any(), any(), any());
}
@Test
@@ -219,7 +281,7 @@ class FileServiceTest {
StoredFile docsDirectory = createDirectory(11L, user, "/", "docs");
StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive");
StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs"))
.thenReturn(Optional.of(docsDirectory));
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
@@ -235,9 +297,10 @@ class FileServiceTest {
@Test
void shouldCopyFileToAnotherDirectory() {
User user = createUser(7L);
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
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.findById(10L)).thenReturn(Optional.of(file));
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 -> {
@@ -252,7 +315,8 @@ class FileServiceTest {
assertThat(response.id()).isEqualTo(20L);
assertThat(response.path()).isEqualTo("/下载");
verify(fileContentStorage).copyFile(7L, "/docs", "/下载", "notes.txt");
assertThat(file.getBlob()).isSameAs(blob);
verify(fileContentStorage, never()).copyFile(any(), any(), any(), any());
}
@Test
@@ -261,9 +325,11 @@ class FileServiceTest {
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
StoredFile targetDirectory = createDirectory(11L, user, "/", "图片");
StoredFile childDirectory = createDirectory(12L, user, "/docs/archive", "nested");
StoredFile childFile = createFile(13L, user, "/docs/archive", "notes.txt");
StoredFile nestedFile = createFile(14L, user, "/docs/archive/nested", "todo.txt");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
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"))
@@ -282,10 +348,7 @@ class FileServiceTest {
FileMetadataResponse response = fileService.copy(user, 10L, "/图片");
assertThat(response.path()).isEqualTo("/图片/archive");
verify(fileContentStorage).ensureDirectory(7L, "/图片/archive");
verify(fileContentStorage).ensureDirectory(7L, "/图片/archive/nested");
verify(fileContentStorage).copyFile(7L, "/docs/archive", "/图片/archive", "notes.txt");
verify(fileContentStorage).copyFile(7L, "/docs/archive/nested", "/图片/archive/nested", "todo.txt");
verify(fileContentStorage, never()).copyFile(any(), any(), any(), any());
}
@Test
@@ -295,7 +358,7 @@ class FileServiceTest {
StoredFile docsDirectory = createDirectory(11L, user, "/", "docs");
StoredFile archiveDirectory = createDirectory(12L, user, "/docs", "archive");
StoredFile descendantDirectory = createDirectory(13L, user, "/docs/archive", "nested");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/", "docs"))
.thenReturn(Optional.of(docsDirectory));
when(storedFileRepository.findByUserIdAndPathAndFilename(7L, "/docs", "archive"))
@@ -313,7 +376,7 @@ class FileServiceTest {
User owner = createUser(1L);
User requester = createUser(2L);
StoredFile storedFile = createFile(100L, owner, "/docs", "notes.txt");
when(storedFileRepository.findById(100L)).thenReturn(Optional.of(storedFile));
when(storedFileRepository.findDetailedById(100L)).thenReturn(Optional.of(storedFile));
assertThatThrownBy(() -> fileService.delete(requester, 100L))
.isInstanceOf(BusinessException.class)
@@ -324,18 +387,51 @@ class FileServiceTest {
void shouldDeleteDirectoryWithNestedFilesViaStorage() {
User user = createUser(7L);
StoredFile directory = createDirectory(10L, user, "/docs", "archive");
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt");
FileBlob blob = createBlob(60L, "blobs/blob-delete", 5L, "text/plain");
StoredFile childFile = createFile(11L, user, "/docs/archive", "nested.txt", blob);
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(childFile));
when(storedFileRepository.countByBlobId(60L)).thenReturn(1L);
fileService.delete(user, 10L);
verify(fileContentStorage).deleteDirectory(7L, "/docs/archive", List.of(childFile));
verify(fileContentStorage).deleteBlob("blobs/blob-delete");
verify(fileBlobRepository).delete(blob);
verify(storedFileRepository).deleteAll(List.of(childFile));
verify(storedFileRepository).delete(directory);
}
@Test
void shouldDeleteSharedBlobOnlyWhenLastReferenceIsRemoved() {
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));
when(storedFileRepository.countByBlobId(70L)).thenReturn(2L);
fileService.delete(user, 15L);
verify(fileContentStorage, never()).deleteBlob(any());
verify(fileBlobRepository, never()).delete(any());
verify(storedFileRepository).delete(storedFile);
}
@Test
void shouldDeleteBlobObjectWhenLastReferenceIsRemoved() {
User user = createUser(7L);
FileBlob blob = createBlob(71L, "blobs/blob-last", 5L, "text/plain");
StoredFile storedFile = createFile(16L, user, "/docs", "last.txt", blob);
when(storedFileRepository.findDetailedById(16L)).thenReturn(Optional.of(storedFile));
when(storedFileRepository.countByBlobId(71L)).thenReturn(1L);
fileService.delete(user, 16L);
verify(fileContentStorage).deleteBlob("blobs/blob-last");
verify(fileBlobRepository).delete(blob);
verify(storedFileRepository).delete(storedFile);
}
@Test
void shouldListFilesByPathWithPagination() {
User user = createUser(7L);
@@ -370,9 +466,9 @@ class FileServiceTest {
void shouldUseSignedDownloadUrlWhenStorageSupportsDirectDownload() {
User user = createUser(7L);
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file));
when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file));
when(fileContentStorage.supportsDirectDownload()).thenReturn(true);
when(fileContentStorage.createDownloadUrl(7L, "/docs", "notes.txt", "notes.txt"))
when(fileContentStorage.createBlobDownloadUrl("blobs/blob-22", "notes.txt"))
.thenReturn("https://download.example.com/file");
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
@@ -384,7 +480,7 @@ class FileServiceTest {
void shouldFallbackToBackendDownloadUrlWhenStorageIsLocal() {
User user = createUser(7L);
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
when(storedFileRepository.findById(22L)).thenReturn(Optional.of(file));
when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file));
when(fileContentStorage.supportsDirectDownload()).thenReturn(false);
DownloadUrlResponse response = fileService.getDownloadUrl(user, 22L);
@@ -401,12 +497,12 @@ class FileServiceTest {
StoredFile childFile = createFile(12L, user, "/docs/archive", "notes.txt");
StoredFile nestedFile = createFile(13L, user, "/docs/archive/nested", "todo.txt");
when(storedFileRepository.findById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(directory));
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive"))
.thenReturn(List.of(childDirectory, childFile, nestedFile));
when(fileContentStorage.readFile(7L, "/docs/archive", "notes.txt"))
when(fileContentStorage.readBlob("blobs/blob-12"))
.thenReturn("hello".getBytes(StandardCharsets.UTF_8));
when(fileContentStorage.readFile(7L, "/docs/archive/nested", "todo.txt"))
when(fileContentStorage.readBlob("blobs/blob-13"))
.thenReturn("world".getBytes(StandardCharsets.UTF_8));
var response = fileService.download(user, 10L);
@@ -430,15 +526,15 @@ class FileServiceTest {
assertThat(entries).containsEntry("archive/nested/", "");
assertThat(entries).containsEntry("archive/notes.txt", "hello");
assertThat(entries).containsEntry("archive/nested/todo.txt", "world");
verify(fileContentStorage).readFile(7L, "/docs/archive", "notes.txt");
verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt");
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.findById(22L)).thenReturn(Optional.of(file));
when(storedFileRepository.findDetailedById(22L)).thenReturn(Optional.of(file));
when(fileShareLinkRepository.save(any(FileShareLink.class))).thenAnswer(invocation -> {
FileShareLink shareLink = invocation.getArgument(0);
shareLink.setId(100L);
@@ -457,7 +553,8 @@ class FileServiceTest {
void shouldImportSharedFileIntoRecipientWorkspace() {
User owner = createUser(7L);
User recipient = createUser(8L);
StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt");
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");
@@ -471,21 +568,30 @@ class FileServiceTest {
file.setId(200L);
return file;
});
when(fileContentStorage.readFile(7L, "/docs", "notes.txt"))
.thenReturn("hello".getBytes(StandardCharsets.UTF_8));
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).storeImportedFile(
eq(8L),
eq("/下载"),
eq("notes.txt"),
eq(sourceFile.getContentType()),
aryEq("hello".getBytes(StandardCharsets.UTF_8))
);
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.existsByUserIdAndPathAndFilename(8L, "/", "下载")).thenReturn(true);
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) {
@@ -498,7 +604,21 @@ class FileServiceTest {
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);
@@ -506,7 +626,8 @@ class FileServiceTest {
file.setPath(path);
file.setSize(5L);
file.setDirectory(false);
file.setStorageName(filename);
file.setContentType("text/plain");
file.setBlob(blob);
file.setCreatedAt(LocalDateTime.now());
return file;
}
@@ -516,6 +637,7 @@ class FileServiceTest {
directory.setDirectory(true);
directory.setContentType("directory");
directory.setSize(0L);
directory.setBlob(null);
return directory;
}
}

View File

@@ -15,7 +15,10 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -50,6 +53,8 @@ class FileShareControllerIntegrationTest {
@Autowired
private StoredFileRepository storedFileRepository;
@Autowired
private FileBlobRepository fileBlobRepository;
@Autowired
private FileShareLinkRepository fileShareLinkRepository;
@@ -58,6 +63,7 @@ class FileShareControllerIntegrationTest {
void setUp() throws Exception {
fileShareLinkRepository.deleteAll();
storedFileRepository.deleteAll();
fileBlobRepository.deleteAll();
userRepository.deleteAll();
if (Files.exists(STORAGE_ROOT)) {
try (var paths = Files.walk(STORAGE_ROOT)) {
@@ -88,19 +94,26 @@ class FileShareControllerIntegrationTest {
recipient.setCreatedAt(LocalDateTime.now());
recipient = userRepository.save(recipient);
FileBlob blob = new FileBlob();
blob.setObjectKey("blobs/share-notes");
blob.setContentType("text/plain");
blob.setSize(5L);
blob.setCreatedAt(LocalDateTime.now());
blob = fileBlobRepository.save(blob);
StoredFile file = new StoredFile();
file.setUser(owner);
file.setFilename("notes.txt");
file.setPath("/docs");
file.setStorageName("notes.txt");
file.setContentType("text/plain");
file.setSize(5L);
file.setDirectory(false);
file.setBlob(blob);
sharedFileId = storedFileRepository.save(file).getId();
Path ownerDir = STORAGE_ROOT.resolve(owner.getId().toString()).resolve("docs");
Files.createDirectories(ownerDir);
Files.writeString(ownerDir.resolve("notes.txt"), "hello", StandardCharsets.UTF_8);
Path blobPath = STORAGE_ROOT.resolve("blobs").resolve("share-notes");
Files.createDirectories(blobPath.getParent());
Files.writeString(blobPath, "hello", StandardCharsets.UTF_8);
}
@Test
@@ -152,6 +165,18 @@ class FileShareControllerIntegrationTest {
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
List<StoredFile> allFiles = storedFileRepository.findAll().stream()
.filter(file -> !file.isDirectory())
.sorted(Comparator.comparing(StoredFile::getId))
.toList();
assertThat(allFiles).hasSize(2);
assertThat(allFiles.get(0).getBlob().getId()).isEqualTo(allFiles.get(1).getBlob().getId());
assertThat(fileBlobRepository.count()).isEqualTo(1L);
try (var paths = Files.walk(STORAGE_ROOT)) {
long physicalObjects = paths.filter(Files::isRegularFile).count();
assertThat(physicalObjects).isEqualTo(1L);
}
}
@Test
@@ -162,7 +187,6 @@ class FileShareControllerIntegrationTest {
downloadDirectory.setUser(owner);
downloadDirectory.setFilename("下载");
downloadDirectory.setPath("/");
downloadDirectory.setStorageName("下载");
downloadDirectory.setContentType("directory");
downloadDirectory.setSize(0L);
downloadDirectory.setDirectory(true);
@@ -198,7 +222,6 @@ class FileShareControllerIntegrationTest {
downloadDirectory.setUser(owner);
downloadDirectory.setFilename("下载");
downloadDirectory.setPath("/");
downloadDirectory.setStorageName("下载");
downloadDirectory.setContentType("directory");
downloadDirectory.setSize(0L);
downloadDirectory.setDirectory(true);
@@ -224,5 +247,13 @@ class FileShareControllerIntegrationTest {
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
List<StoredFile> allFiles = storedFileRepository.findAll().stream()
.filter(file -> !file.isDirectory())
.sorted(Comparator.comparing(StoredFile::getId))
.toList();
assertThat(allFiles).hasSize(2);
assertThat(allFiles.get(0).getBlob().getId()).isEqualTo(allFiles.get(1).getBlob().getId());
assertThat(fileBlobRepository.count()).isEqualTo(1L);
}
}

View File

@@ -144,7 +144,7 @@ class TransferControllerIntegrationTest {
@Test
@WithMockUser(username = "alice")
void shouldRejectAnonymousOfflineLookupJoinAndDownload() throws Exception {
void shouldAllowAnonymousOfflineLookupJoinAndDownloadButKeepImportProtected() throws Exception {
String response = mockMvc.perform(post("/api/transfer/sessions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
@@ -176,12 +176,27 @@ class TransferControllerIntegrationTest {
.andExpect(status().isOk());
mockMvc.perform(get("/api/transfer/sessions/lookup").with(anonymous()).param("pickupCode", pickupCode))
.andExpect(status().isUnauthorized());
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
.andExpect(jsonPath("$.data.mode").value("OFFLINE"));
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId).with(anonymous()))
.andExpect(status().isUnauthorized());
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
.andExpect(jsonPath("$.data.files[0].name").value("offline.txt"));
mockMvc.perform(get("/api/transfer/sessions/{sessionId}/files/{fileId}/download", sessionId, fileId).with(anonymous()))
.andExpect(status().isOk())
.andExpect(content().bytes("hello offline".getBytes(StandardCharsets.UTF_8)));
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/files/{fileId}/import", sessionId, fileId)
.with(anonymous())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"path": "/"
}
"""))
.andExpect(status().isUnauthorized());
}