Enable dual-device login and mobile APK update checks

This commit is contained in:
yoyuzh
2026-04-03 16:28:09 +08:00
parent 56f2a9fe0d
commit 52b5bbfe8e
50 changed files with 1659 additions and 164 deletions

View File

@@ -120,7 +120,7 @@ class AuthControllerValidationTest {
"new-refresh-token",
new UserProfileResponse(7L, "alice", "alice@example.com", LocalDateTime.now())
);
when(authService.refresh("refresh-1")).thenReturn(response);
when(authService.refresh("refresh-1", AuthClientType.DESKTOP)).thenReturn(response);
mockMvc.perform(post("/api/auth/refresh")
.contentType(MediaType.APPLICATION_JSON)
@@ -134,6 +134,6 @@ class AuthControllerValidationTest {
.andExpect(jsonPath("$.data.refreshToken").value("new-refresh-token"))
.andExpect(jsonPath("$.data.user.username").value("alice"));
verify(authService).refresh("refresh-1");
verify(authService).refresh("refresh-1", AuthClientType.DESKTOP);
}
}

View File

@@ -84,8 +84,8 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now());
return user;
});
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(any(User.class), eq(AuthClientType.DESKTOP))).thenReturn("refresh-token");
AuthResponse response = authService.register(request);
@@ -166,8 +166,8 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now());
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(user)).thenReturn("refresh-token");
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(user, AuthClientType.DESKTOP)).thenReturn("refresh-token");
AuthResponse response = authService.login(request);
@@ -188,9 +188,9 @@ class AuthServiceTest {
user.setEmail("alice@example.com");
user.setCreatedAt(LocalDateTime.now());
when(refreshTokenService.rotateRefreshToken("old-refresh"))
.thenReturn(new RefreshTokenService.RotatedRefreshToken(user, "new-refresh"));
.thenReturn(new RefreshTokenService.RotatedRefreshToken(user, "new-refresh", AuthClientType.DESKTOP));
when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("new-access");
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("new-access");
AuthResponse response = authService.refresh("old-refresh");
@@ -232,8 +232,8 @@ class AuthServiceTest {
user.setCreatedAt(LocalDateTime.now());
return user;
});
when(jwtTokenProvider.generateAccessToken(eq(9L), eq("demo"), anyString())).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(any(User.class))).thenReturn("refresh-token");
when(jwtTokenProvider.generateAccessToken(eq(9L), eq("demo"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("access-token");
when(refreshTokenService.issueRefreshToken(any(User.class), eq(AuthClientType.DESKTOP))).thenReturn("refresh-token");
AuthResponse response = authService.devLogin("demo");
@@ -296,7 +296,7 @@ class AuthServiceTest {
when(passwordEncoder.matches("OldPass1!", "encoded-old")).thenReturn(true);
when(passwordEncoder.encode("NewPass1!A")).thenReturn("encoded-new");
when(userRepository.save(user)).thenReturn(user);
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString())).thenReturn("new-access");
when(jwtTokenProvider.generateAccessToken(eq(1L), eq("alice"), anyString(), eq(AuthClientType.DESKTOP))).thenReturn("new-access");
when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh");
AuthResponse response = authService.changePassword("alice", request);

View File

@@ -38,16 +38,78 @@ class AuthSingleDeviceIntegrationTest {
@Autowired
private UserRepository userRepository;
@Autowired
private com.yoyuzh.files.StoredFileRepository storedFileRepository;
@Autowired
private RefreshTokenRepository refreshTokenRepository;
@Autowired
private PasswordEncoder passwordEncoder;
@BeforeEach
void setUp() {
storedFileRepository.deleteAll();
refreshTokenRepository.deleteAll();
userRepository.deleteAll();
}
@Test
void shouldInvalidatePreviousAccessTokenAfterLoggingInAgain() throws Exception {
void shouldKeepDesktopAndMobileAccessTokensValidAtTheSameTime() throws Exception {
User user = new User();
user.setUsername("alice");
user.setDisplayName("Alice");
user.setEmail("alice@example.com");
user.setPhoneNumber("13800138000");
user.setPasswordHash(passwordEncoder.encode("StrongPass1!"));
user.setPreferredLanguage("zh-CN");
user.setRole(UserRole.USER);
user.setCreatedAt(LocalDateTime.now());
userRepository.save(user);
String loginRequest = """
{
"username": "alice",
"password": "StrongPass1!"
}
""";
String desktopLoginResponse = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.header("X-Yoyuzh-Client", "desktop")
.content(loginRequest))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andReturn()
.getResponse()
.getContentAsString();
String mobileLoginResponse = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.header("X-Yoyuzh-Client", "mobile")
.content(loginRequest))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
.andReturn()
.getResponse()
.getContentAsString();
String desktopAccessToken = JsonPath.read(desktopLoginResponse, "$.data.accessToken");
String mobileAccessToken = JsonPath.read(mobileLoginResponse, "$.data.accessToken");
mockMvc.perform(get("/api/user/profile")
.header("Authorization", "Bearer " + desktopAccessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.username").value("alice"));
mockMvc.perform(get("/api/user/profile")
.header("Authorization", "Bearer " + mobileAccessToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.username").value("alice"));
}
@Test
void shouldInvalidatePreviousAccessTokenAfterLoggingInAgainOnTheSameClientType() throws Exception {
User user = new User();
user.setUsername("alice");
user.setDisplayName("Alice");
@@ -68,6 +130,7 @@ class AuthSingleDeviceIntegrationTest {
String firstLoginResponse = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.header("X-Yoyuzh-Client", "desktop")
.content(loginRequest))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())
@@ -77,6 +140,7 @@ class AuthSingleDeviceIntegrationTest {
String secondLoginResponse = mockMvc.perform(post("/api/auth/login")
.contentType("application/json")
.header("X-Yoyuzh-Client", "desktop")
.content(loginRequest))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.accessToken").isNotEmpty())

View File

@@ -59,7 +59,7 @@ class JwtTokenProviderTest {
JwtTokenProvider provider = new JwtTokenProvider(properties);
provider.init();
String token = provider.generateAccessToken(7L, "alice", "session-1");
String token = provider.generateAccessToken(7L, "alice", "session-1", AuthClientType.MOBILE);
SecretKey secretKey = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
Instant expiration = Jwts.parser().verifyWith(secretKey).build()
.parseSignedClaims(token)
@@ -71,6 +71,7 @@ class JwtTokenProviderTest {
assertThat(provider.getUsername(token)).isEqualTo("alice");
assertThat(provider.getUserId(token)).isEqualTo(7L);
assertThat(provider.getSessionId(token)).isEqualTo("session-1");
assertThat(provider.getClientType(token)).isEqualTo(AuthClientType.MOBILE);
assertThat(provider.hasMatchingSession(token, "session-1")).isTrue();
assertThat(provider.hasMatchingSession(token, "session-2")).isFalse();
assertThat(expiration).isAfter(Instant.now().plusSeconds(850));

View File

@@ -104,11 +104,11 @@ class JwtAuthenticationFilterTest {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer valid-token");
MockHttpServletResponse response = new MockHttpServletResponse();
User domainUser = createDomainUser("alice", "session-1");
User domainUser = createDomainUser("alice", "session-1", null);
when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true);
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser);
when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(false);
when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(false);
filter.doFilterInternal(request, response, filterChain);
@@ -121,7 +121,7 @@ class JwtAuthenticationFilterTest {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer valid-token");
MockHttpServletResponse response = new MockHttpServletResponse();
User domainUser = createDomainUser("alice", "session-1");
User domainUser = createDomainUser("alice", "session-1", null);
UserDetails disabledUserDetails = org.springframework.security.core.userdetails.User.builder()
.username("alice")
.password("hashed")
@@ -131,7 +131,7 @@ class JwtAuthenticationFilterTest {
when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true);
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser);
when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(true);
when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(true);
when(userDetailsService.loadUserByUsername("alice")).thenReturn(disabledUserDetails);
filter.doFilterInternal(request, response, filterChain);
@@ -145,7 +145,7 @@ class JwtAuthenticationFilterTest {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer valid-token");
MockHttpServletResponse response = new MockHttpServletResponse();
User domainUser = createDomainUser("alice", "session-1");
User domainUser = createDomainUser("alice", "session-1", null);
UserDetails activeUserDetails = org.springframework.security.core.userdetails.User.builder()
.username("alice")
.password("hashed")
@@ -154,7 +154,7 @@ class JwtAuthenticationFilterTest {
when(jwtTokenProvider.validateToken("valid-token")).thenReturn(true);
when(jwtTokenProvider.getUsername("valid-token")).thenReturn("alice");
when(userDetailsService.loadDomainUser("alice")).thenReturn(domainUser);
when(jwtTokenProvider.hasMatchingSession("valid-token", "session-1")).thenReturn(true);
when(jwtTokenProvider.hasMatchingSession("valid-token", domainUser)).thenReturn(true);
when(userDetailsService.loadUserByUsername("alice")).thenReturn(activeUserDetails);
filter.doFilterInternal(request, response, filterChain);
@@ -165,13 +165,15 @@ class JwtAuthenticationFilterTest {
verify(adminMetricsService).recordUserOnline(1L, "alice");
}
private User createDomainUser(String username, String sessionId) {
private User createDomainUser(String username, String desktopSessionId, String mobileSessionId) {
User user = new User();
user.setId(1L);
user.setUsername(username);
user.setEmail(username + "@example.com");
user.setPasswordHash("hashed");
user.setActiveSessionId(sessionId);
user.setActiveSessionId(desktopSessionId);
user.setDesktopActiveSessionId(desktopSessionId);
user.setMobileActiveSessionId(mobileSessionId);
user.setCreatedAt(LocalDateTime.now());
return user;
}

View File

@@ -158,7 +158,8 @@ class FileServiceTest {
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(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));
@@ -174,7 +175,8 @@ class FileServiceTest {
void shouldDeleteCompletedUploadBlobWhenMetadataSaveFails() {
User user = createUser(7L);
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "docs")).thenReturn(true);
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));
@@ -190,8 +192,8 @@ class FileServiceTest {
void shouldCreateMissingDirectoriesBeforeCompletingNestedUpload() {
User user = createUser(7L);
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(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));
@@ -384,52 +386,74 @@ class FileServiceTest {
}
@Test
void shouldDeleteDirectoryWithNestedFilesViaStorage() {
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(childFile));
when(storedFileRepository.countByBlobId(60L)).thenReturn(1L);
when(storedFileRepository.findByUserIdAndPathEqualsOrDescendant(7L, "/docs/archive")).thenReturn(List.of(nestedDirectory, childFile));
fileService.delete(user, 10L);
verify(fileContentStorage).deleteBlob("blobs/blob-delete");
verify(fileBlobRepository).delete(blob);
verify(storedFileRepository).deleteAll(List.of(childFile));
verify(storedFileRepository).delete(directory);
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 shouldDeleteSharedBlobOnlyWhenLastReferenceIsRemoved() {
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));
when(storedFileRepository.countByBlobId(70L)).thenReturn(2L);
fileService.delete(user, 15L);
assertThat(storedFile.getDeletedAt()).isNotNull();
assertThat(storedFile.isRecycleRoot()).isTrue();
verify(fileContentStorage, never()).deleteBlob(any());
verify(fileBlobRepository, never()).delete(any());
verify(storedFileRepository).delete(storedFile);
verify(storedFileRepository, never()).delete(any());
}
@Test
void shouldDeleteBlobObjectWhenLastReferenceIsRemoved() {
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);
when(storedFileRepository.findDetailedById(16L)).thenReturn(Optional.of(storedFile));
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.delete(user, 16L);
fileService.pruneExpiredRecycleBinItems();
verify(fileContentStorage).deleteBlob("blobs/blob-last");
verify(fileBlobRepository).delete(blob);
verify(storedFileRepository).delete(storedFile);
verify(storedFileRepository).deleteAll(List.of(storedFile));
}
@Test
@@ -582,7 +606,8 @@ class FileServiceTest {
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(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));

View File

@@ -0,0 +1,171 @@
package com.yoyuzh.files;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:recycle_bin_api_test;MODE=MySQL;DB_CLOSE_DELAY=-1",
"spring.datasource.driver-class-name=org.h2.Driver",
"spring.datasource.username=sa",
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef",
"app.storage.root-dir=./target/test-storage-recycle-bin"
}
)
@AutoConfigureMockMvc
class RecycleBinControllerIntegrationTest {
private static final Path STORAGE_ROOT = Path.of("./target/test-storage-recycle-bin").toAbsolutePath().normalize();
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private StoredFileRepository storedFileRepository;
@Autowired
private FileBlobRepository fileBlobRepository;
private Long deletedFileId;
@BeforeEach
void setUp() throws Exception {
storedFileRepository.deleteAll();
fileBlobRepository.deleteAll();
userRepository.deleteAll();
if (Files.exists(STORAGE_ROOT)) {
try (var paths = Files.walk(STORAGE_ROOT)) {
paths.sorted((left, right) -> right.compareTo(left)).forEach(path -> {
try {
Files.deleteIfExists(path);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
});
}
}
Files.createDirectories(STORAGE_ROOT);
User owner = new User();
owner.setUsername("alice");
owner.setEmail("alice@example.com");
owner.setPhoneNumber("13800138000");
owner.setPasswordHash("encoded-password");
owner.setCreatedAt(LocalDateTime.now());
owner = userRepository.save(owner);
StoredFile docsDirectory = new StoredFile();
docsDirectory.setUser(owner);
docsDirectory.setFilename("docs");
docsDirectory.setPath("/");
docsDirectory.setContentType("directory");
docsDirectory.setSize(0L);
docsDirectory.setDirectory(true);
storedFileRepository.save(docsDirectory);
FileBlob blob = new FileBlob();
blob.setObjectKey("blobs/recycle-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.setContentType("text/plain");
file.setSize(5L);
file.setDirectory(false);
file.setBlob(blob);
deletedFileId = storedFileRepository.save(file).getId();
Path blobPath = STORAGE_ROOT.resolve("blobs").resolve("recycle-notes");
Files.createDirectories(blobPath.getParent());
Files.writeString(blobPath, "hello", StandardCharsets.UTF_8);
}
@Test
void shouldDeleteListAndRestoreFileThroughRecycleBinApi() throws Exception {
mockMvc.perform(delete("/api/files/{fileId}", deletedFileId)
.with(user("alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(get("/api/files/list")
.with(user("alice"))
.param("path", "/docs")
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items").isEmpty());
String recycleResponse = mockMvc.perform(get("/api/files/recycle-bin")
.with(user("alice"))
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"))
.andExpect(jsonPath("$.data.items[0].path").value("/docs"))
.andExpect(jsonPath("$.data.items[0].deletedAt").isNotEmpty())
.andReturn()
.getResponse()
.getContentAsString();
Number recycleRootId = JsonPath.read(recycleResponse, "$.data.items[0].id");
mockMvc.perform(post("/api/files/recycle-bin/{fileId}/restore", recycleRootId)
.with(user("alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
.andExpect(jsonPath("$.data.path").value("/docs"));
mockMvc.perform(get("/api/files/recycle-bin")
.with(user("alice"))
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items").isEmpty());
mockMvc.perform(get("/api/files/list")
.with(user("alice"))
.param("path", "/docs")
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
StoredFile restoredFile = storedFileRepository.findById(deletedFileId).orElseThrow();
assertThat(restoredFile.getDeletedAt()).isNull();
assertThat(restoredFile.getRecycleGroupId()).isNull();
assertThat(restoredFile.getRecycleOriginalPath()).isNull();
}
}