实现快传,完善快传和网盘的功能,实现文件的互传等一系列功能

This commit is contained in:
yoyuzh
2026-03-20 14:16:18 +08:00
parent 944ab6dbf8
commit 43358e29d7
109 changed files with 5237 additions and 2465 deletions

View File

@@ -33,9 +33,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
"spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef",
"app.admin.usernames=admin",
"app.storage.root-dir=./target/test-storage-admin",
"app.cqu.require-login=true",
"app.cqu.mock-enabled=false"
"app.storage.root-dir=./target/test-storage-admin"
}
)
@AutoConfigureMockMvc
@@ -66,8 +64,6 @@ class AdminControllerIntegrationTest {
portalUser.setPhoneNumber("13800138000");
portalUser.setPasswordHash("encoded-password");
portalUser.setCreatedAt(LocalDateTime.now());
portalUser.setLastSchoolStudentId("20230001");
portalUser.setLastSchoolSemester("2025-2026-1");
portalUser = userRepository.save(portalUser);
secondaryUser = new User();
@@ -109,15 +105,13 @@ class AdminControllerIntegrationTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.items[0].username").value("alice"))
.andExpect(jsonPath("$.data.items[0].phoneNumber").value("13800138000"))
.andExpect(jsonPath("$.data.items[0].lastSchoolStudentId").value("20230001"))
.andExpect(jsonPath("$.data.items[0].role").value("USER"))
.andExpect(jsonPath("$.data.items[0].banned").value(false));
mockMvc.perform(get("/api/admin/summary"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalUsers").value(2))
.andExpect(jsonPath("$.data.totalFiles").value(2))
.andExpect(jsonPath("$.data.usersWithSchoolCache").value(1));
.andExpect(jsonPath("$.data.totalFiles").value(2));
}
@Test

View File

@@ -28,9 +28,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
"spring.datasource.password=",
"spring.jpa.hibernate.ddl-auto=create-drop",
"app.jwt.secret=0123456789abcdef0123456789abcdef",
"app.storage.root-dir=./target/test-storage-refresh",
"app.cqu.require-login=true",
"app.cqu.mock-enabled=false"
"app.storage.root-dir=./target/test-storage-refresh"
}
)
class RefreshTokenServiceIntegrationTest {

View File

@@ -28,4 +28,5 @@ class SecurityConfigTest {
assertThat(configuration).isNotNull();
assertThat(configuration.getAllowedMethods()).contains("PATCH");
}
}

View File

@@ -1,202 +0,0 @@
package com.yoyuzh.cqu;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.config.CquApiProperties;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CquDataServiceTest {
@Mock
private CquApiClient cquApiClient;
@Mock
private CourseRepository courseRepository;
@Mock
private GradeRepository gradeRepository;
@Mock
private UserRepository userRepository;
@InjectMocks
private CquDataService cquDataService;
@Test
void shouldNormalizeScheduleFromRemoteApi() {
CquApiProperties properties = new CquApiProperties();
properties.setRequireLogin(false);
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
when(cquApiClient.fetchSchedule("2025-2026-1", "20230001")).thenReturn(List.of(Map.of(
"courseName", "Java",
"teacher", "Zhang",
"classroom", "A101",
"dayOfWeek", 1,
"startTime", 1,
"endTime", 2
)));
List<CourseResponse> response = cquDataService.getSchedule(null, "2025-2026-1", "20230001");
assertThat(response).hasSize(1);
assertThat(response.get(0).courseName()).isEqualTo("Java");
assertThat(response.get(0).teacher()).isEqualTo("Zhang");
}
@Test
void shouldPersistGradesForLoggedInUserWhenAvailable() {
CquApiProperties properties = new CquApiProperties();
properties.setRequireLogin(true);
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
User user = new User();
user.setId(1L);
user.setUsername("alice");
user.setEmail("alice@example.com");
user.setPasswordHash("encoded");
user.setCreatedAt(LocalDateTime.now());
when(cquApiClient.fetchGrades("2025-2026-1", "20230001")).thenReturn(List.of(Map.of(
"courseName", "Java",
"grade", 95,
"semester", "2025-2026-1"
)));
Grade persisted = new Grade();
persisted.setUser(user);
persisted.setCourseName("Java");
persisted.setGrade(95D);
persisted.setSemester("2025-2026-1");
persisted.setStudentId("20230001");
when(gradeRepository.saveAll(anyList())).thenReturn(List.of(persisted));
when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001"))
.thenReturn(List.of(persisted));
List<GradeResponse> response = cquDataService.getGrades(user, "2025-2026-1", "20230001");
assertThat(response).hasSize(1);
assertThat(response.get(0).grade()).isEqualTo(95D);
}
@Test
void shouldReturnPersistedScheduleWithoutCallingRemoteApiWhenRefreshIsDisabled() {
CquApiProperties properties = new CquApiProperties();
properties.setRequireLogin(true);
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
User user = new User();
user.setId(1L);
user.setUsername("alice");
Course persisted = new Course();
persisted.setUser(user);
persisted.setCourseName("Java");
persisted.setTeacher("Zhang");
persisted.setClassroom("A101");
persisted.setDayOfWeek(1);
persisted.setStartTime(1);
persisted.setEndTime(2);
persisted.setSemester("2025-spring");
persisted.setStudentId("20230001");
when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring"))
.thenReturn(List.of(persisted));
List<CourseResponse> response = cquDataService.getSchedule(user, "2025-spring", "20230001", false);
assertThat(response).extracting(CourseResponse::courseName).containsExactly("Java");
verifyNoInteractions(cquApiClient);
}
@Test
void shouldReturnLatestStoredSchoolDataFromPersistedUserContext() {
CquApiProperties properties = new CquApiProperties();
properties.setRequireLogin(true);
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
User user = new User();
user.setId(1L);
user.setUsername("alice");
user.setLastSchoolStudentId("20230001");
user.setLastSchoolSemester("2025-spring");
Course course = new Course();
course.setUser(user);
course.setCourseName("Java");
course.setTeacher("Zhang");
course.setClassroom("A101");
course.setDayOfWeek(1);
course.setStartTime(1);
course.setEndTime(2);
course.setSemester("2025-spring");
course.setStudentId("20230001");
Grade grade = new Grade();
grade.setUser(user);
grade.setCourseName("Java");
grade.setGrade(95D);
grade.setSemester("2025-spring");
grade.setStudentId("20230001");
when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring"))
.thenReturn(List.of(course));
when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001"))
.thenReturn(List.of(grade));
LatestSchoolDataResponse response = cquDataService.getLatest(user);
assertThat(response.studentId()).isEqualTo("20230001");
assertThat(response.semester()).isEqualTo("2025-spring");
assertThat(response.schedule()).extracting(CourseResponse::courseName).containsExactly("Java");
assertThat(response.grades()).extracting(GradeResponse::courseName).containsExactly("Java");
}
@Test
void shouldFallbackToMostRecentStoredSchoolDataWhenUserContextIsEmpty() {
CquApiProperties properties = new CquApiProperties();
properties.setRequireLogin(true);
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, userRepository, properties);
User user = new User();
user.setId(1L);
user.setUsername("alice");
Course latestCourse = new Course();
latestCourse.setUser(user);
latestCourse.setCourseName("Java");
latestCourse.setTeacher("Zhang");
latestCourse.setClassroom("A101");
latestCourse.setDayOfWeek(1);
latestCourse.setStartTime(1);
latestCourse.setEndTime(2);
latestCourse.setSemester("2025-spring");
latestCourse.setStudentId("20230001");
latestCourse.setCreatedAt(LocalDateTime.now());
when(courseRepository.findTopByUserIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.of(latestCourse));
when(gradeRepository.findTopByUserIdOrderByCreatedAtDesc(1L)).thenReturn(Optional.empty());
when(courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(1L, "20230001", "2025-spring"))
.thenReturn(List.of(latestCourse));
when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001"))
.thenReturn(List.of());
LatestSchoolDataResponse response = cquDataService.getLatest(user);
assertThat(response.studentId()).isEqualTo("20230001");
assertThat(response.semester()).isEqualTo("2025-spring");
assertThat(user.getLastSchoolStudentId()).isEqualTo("20230001");
assertThat(user.getLastSchoolSemester()).isEqualTo("2025-spring");
}
}

View File

@@ -1,83 +0,0 @@
package com.yoyuzh.cqu;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:cqu_tx_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.cqu.require-login=true",
"app.cqu.mock-enabled=false"
}
)
class CquDataServiceTransactionTest {
@Autowired
private CquDataService cquDataService;
@Autowired
private UserRepository userRepository;
@Autowired
private GradeRepository gradeRepository;
@MockBean
private CquApiClient cquApiClient;
@Test
void shouldPersistGradesInsideTransactionForLoggedInUser() {
User user = new User();
user.setUsername("portal-demo");
user.setEmail("portal-demo@example.com");
user.setPasswordHash("encoded");
user = userRepository.save(user);
Grade existing = new Grade();
existing.setUser(user);
existing.setCourseName("Old Java");
existing.setGrade(60D);
existing.setSemester("2025-spring");
existing.setStudentId("2023123456");
gradeRepository.save(existing);
when(cquApiClient.fetchGrades("2025-spring", "2023123456")).thenReturn(List.of(
Map.of(
"courseName", "Java",
"grade", 95,
"semester", "2025-spring"
)
));
List<GradeResponse> response = cquDataService.getGrades(user, "2025-spring", "2023123456", true);
assertThat(response).hasSize(1);
assertThat(response.get(0).courseName()).isEqualTo("Java");
assertThat(response.get(0).grade()).isEqualTo(95D);
assertThat(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(user.getId(), "2023123456"))
.hasSize(1)
.first()
.extracting(Grade::getCourseName)
.isEqualTo("Java");
assertThat(userRepository.findById(user.getId()))
.get()
.extracting(User::getLastSchoolStudentId, User::getLastSchoolSemester)
.containsExactly("2023123456", "2025-spring");
}
}

View File

@@ -1,42 +0,0 @@
package com.yoyuzh.cqu;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class CquMockDataFactoryTest {
@Test
void shouldCreateMockScheduleForStudentAndSemester() {
List<Map<String, Object>> result = CquMockDataFactory.createSchedule("2025-2026-1", "20230001");
assertThat(result).isNotEmpty();
assertThat(result.get(0)).containsEntry("courseName", "高级 Java 程序设计");
assertThat(result.get(0)).containsEntry("semester", "2025-2026-1");
}
@Test
void shouldCreateMockGradesForStudentAndSemester() {
List<Map<String, Object>> result = CquMockDataFactory.createGrades("2025-2026-1", "20230001");
assertThat(result).isNotEmpty();
assertThat(result.get(0)).containsEntry("studentId", "20230001");
assertThat(result.get(0)).containsKey("grade");
}
@Test
void shouldReturnDifferentMockDataForDifferentStudents() {
List<Map<String, Object>> firstSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2023123456");
List<Map<String, Object>> secondSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2022456789");
List<Map<String, Object>> firstGrades = CquMockDataFactory.createGrades("2025-2026-1", "2023123456");
List<Map<String, Object>> secondGrades = CquMockDataFactory.createGrades("2025-2026-1", "2022456789");
assertThat(firstSchedule).extracting(item -> item.get("courseName"))
.isNotEqualTo(secondSchedule.stream().map(item -> item.get("courseName")).toList());
assertThat(firstGrades).extracting(item -> item.get("grade"))
.isNotEqualTo(secondGrades.stream().map(item -> item.get("grade")).toList());
}
}

View File

@@ -27,6 +27,7 @@ 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.never;
@@ -43,13 +44,16 @@ class FileServiceTest {
@Mock
private FileContentStorage fileContentStorage;
@Mock
private FileShareLinkRepository fileShareLinkRepository;
private FileService fileService;
@BeforeEach
void setUp() {
FileStorageProperties properties = new FileStorageProperties();
properties.setMaxFileSize(500L * 1024 * 1024);
fileService = new FileService(storedFileRepository, fileContentStorage, properties);
fileService = new FileService(storedFileRepository, fileContentStorage, fileShareLinkRepository, properties);
}
@Test
@@ -167,6 +171,140 @@ class FileServiceTest {
verify(fileContentStorage).renameDirectory(7L, "/docs/archive", "/docs/renamed-archive", List.of(childFile));
}
@Test
void shouldMoveFileToAnotherDirectory() {
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.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).moveFile(7L, "/docs", "/下载", "notes.txt");
}
@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.findById(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).renameDirectory(7L, "/docs/archive", "/图片/archive", List.of(childFile));
}
@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.findById(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);
StoredFile file = createFile(10L, user, "/docs", "notes.txt");
StoredFile targetDirectory = createDirectory(11L, user, "/", "下载");
when(storedFileRepository.findById(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("/下载");
verify(fileContentStorage).copyFile(7L, "/docs", "/下载", "notes.txt");
}
@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");
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));
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).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");
}
@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.findById(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);
@@ -293,6 +431,60 @@ class FileServiceTest {
verify(fileContentStorage).readFile(7L, "/docs/archive/nested", "todo.txt");
}
@Test
void shouldCreateShareLinkForOwnedFile() {
User user = createUser(7L);
StoredFile file = createFile(22L, user, "/docs", "notes.txt");
when(storedFileRepository.findById(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);
StoredFile sourceFile = createFile(22L, owner, "/docs", "notes.txt");
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;
});
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))
);
}
private User createUser(Long id) {
User user = new User();
user.setId(id);

View File

@@ -0,0 +1,228 @@
package com.yoyuzh.files;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
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.http.MediaType;
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.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;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
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:file_share_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-file-share"
}
)
@AutoConfigureMockMvc
class FileShareControllerIntegrationTest {
private static final Path STORAGE_ROOT = Path.of("./target/test-storage-file-share").toAbsolutePath().normalize();
private Long sharedFileId;
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private StoredFileRepository storedFileRepository;
@Autowired
private FileShareLinkRepository fileShareLinkRepository;
@BeforeEach
void setUp() throws Exception {
fileShareLinkRepository.deleteAll();
storedFileRepository.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);
User recipient = new User();
recipient.setUsername("bob");
recipient.setEmail("bob@example.com");
recipient.setPhoneNumber("13800138001");
recipient.setPasswordHash("encoded-password");
recipient.setCreatedAt(LocalDateTime.now());
recipient = userRepository.save(recipient);
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);
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);
}
@Test
void shouldCreateInspectAndImportSharedFile() throws Exception {
String response = mockMvc.perform(post("/api/files/{fileId}/share-links", sharedFileId)
.with(user("alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.token").isNotEmpty())
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
.andReturn()
.getResponse()
.getContentAsString();
String token = com.jayway.jsonpath.JsonPath.read(response, "$.data.token");
mockMvc.perform(get("/api/files/share-links/{token}", token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.token").value(token))
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
.andExpect(jsonPath("$.data.ownerUsername").value("alice"));
mockMvc.perform(post("/api/files/share-links/{token}/import", token)
.with(anonymous())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"path": "/下载"
}
"""))
.andExpect(status().isUnauthorized());
mockMvc.perform(post("/api/files/share-links/{token}/import", token)
.with(user("bob"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"path": "/下载"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
.andExpect(jsonPath("$.data.path").value("/下载"));
mockMvc.perform(get("/api/files/list")
.with(user("bob"))
.param("path", "/下载")
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
}
@Test
void shouldMoveFileIntoAnotherDirectoryThroughApi() throws Exception {
User owner = userRepository.findByUsername("alice").orElseThrow();
StoredFile downloadDirectory = new StoredFile();
downloadDirectory.setUser(owner);
downloadDirectory.setFilename("下载");
downloadDirectory.setPath("/");
downloadDirectory.setStorageName("下载");
downloadDirectory.setContentType("directory");
downloadDirectory.setSize(0L);
downloadDirectory.setDirectory(true);
storedFileRepository.save(downloadDirectory);
Files.createDirectories(STORAGE_ROOT.resolve(owner.getId().toString()).resolve("下载"));
mockMvc.perform(patch("/api/files/{fileId}/move", sharedFileId)
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"path": "/下载"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
.andExpect(jsonPath("$.data.path").value("/下载"));
mockMvc.perform(get("/api/files/list")
.with(user("alice"))
.param("path", "/下载")
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
}
@Test
void shouldCopyFileIntoAnotherDirectoryThroughApi() throws Exception {
User owner = userRepository.findByUsername("alice").orElseThrow();
StoredFile downloadDirectory = new StoredFile();
downloadDirectory.setUser(owner);
downloadDirectory.setFilename("下载");
downloadDirectory.setPath("/");
downloadDirectory.setStorageName("下载");
downloadDirectory.setContentType("directory");
downloadDirectory.setSize(0L);
downloadDirectory.setDirectory(true);
storedFileRepository.save(downloadDirectory);
Files.createDirectories(STORAGE_ROOT.resolve(owner.getId().toString()).resolve("下载"));
mockMvc.perform(post("/api/files/{fileId}/copy", sharedFileId)
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"path": "/下载"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
.andExpect(jsonPath("$.data.path").value("/下载"));
mockMvc.perform(get("/api/files/list")
.with(user("alice"))
.param("path", "/下载")
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
}
}

View File

@@ -0,0 +1,123 @@
package com.yoyuzh.transfer;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
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.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
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:transfer_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-transfer"
}
)
@AutoConfigureMockMvc
class TransferControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
User portalUser = new User();
portalUser.setUsername("alice");
portalUser.setEmail("alice@example.com");
portalUser.setPhoneNumber("13800138000");
portalUser.setPasswordHash("encoded-password");
portalUser.setCreatedAt(LocalDateTime.now());
userRepository.save(portalUser);
}
@Test
@WithMockUser(username = "alice")
void shouldCreateLookupJoinAndPollTransferSignals() throws Exception {
String response = mockMvc.perform(post("/api/transfer/sessions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"files": [
{"name": "report.pdf", "size": 2048, "contentType": "application/pdf"}
]
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.sessionId").isNotEmpty())
.andExpect(jsonPath("$.data.pickupCode").isString())
.andExpect(jsonPath("$.data.files[0].name").value("report.pdf"))
.andReturn()
.getResponse()
.getContentAsString();
String sessionId = com.jayway.jsonpath.JsonPath.read(response, "$.data.sessionId");
String pickupCode = com.jayway.jsonpath.JsonPath.read(response, "$.data.pickupCode");
mockMvc.perform(get("/api/transfer/sessions/lookup").param("pickupCode", pickupCode))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
.andExpect(jsonPath("$.data.pickupCode").value(pickupCode));
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", sessionId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.sessionId").value(sessionId))
.andExpect(jsonPath("$.data.files[0].name").value("report.pdf"));
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/signals", sessionId)
.param("role", "sender")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"type": "offer",
"payload": "{\\\"sdp\\\":\\\"demo-offer\\\"}"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(get("/api/transfer/sessions/{sessionId}/signals", sessionId)
.param("role", "receiver")
.param("after", "0"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].type").value("offer"))
.andExpect(jsonPath("$.data.items[0].payload").value("{\"sdp\":\"demo-offer\"}"))
.andExpect(jsonPath("$.data.nextCursor").value(1));
}
@Test
void shouldRejectAnonymousSessionCreationButAllowPublicJoinEndpoints() throws Exception {
mockMvc.perform(post("/api/transfer/sessions")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"files":[{"name":"demo.txt","size":12,"contentType":"text/plain"}]}
"""))
.andExpect(status().isUnauthorized());
mockMvc.perform(post("/api/transfer/sessions/{sessionId}/join", "missing-session"))
.andExpect(status().isNotFound());
}
}

View File

@@ -0,0 +1,54 @@
package com.yoyuzh.transfer;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class TransferSessionTest {
@Test
void shouldEmitPeerJoinedOnlyOnceWhenReceiverJoinsRepeatedly() {
TransferSession session = new TransferSession(
"session-1",
"849201",
Instant.parse("2026-03-20T12:00:00Z"),
List.of(new TransferFileItem("report.pdf", 2048, "application/pdf"))
);
session.markReceiverJoined();
session.markReceiverJoined();
PollTransferSignalsResponse senderSignals = session.poll(TransferRole.SENDER, 0);
assertThat(senderSignals.items())
.extracting(TransferSignalEnvelope::type)
.containsExactly("peer-joined");
assertThat(senderSignals.nextCursor()).isEqualTo(1);
}
@Test
void shouldRouteSignalsToTheOppositeRoleQueue() {
TransferSession session = new TransferSession(
"session-1",
"849201",
Instant.parse("2026-03-20T12:00:00Z"),
List.of(new TransferFileItem("report.pdf", 2048, "application/pdf"))
);
session.enqueue(TransferRole.SENDER, "offer", "{\"sdp\":\"demo-offer\"}");
session.enqueue(TransferRole.RECEIVER, "answer", "{\"sdp\":\"demo-answer\"}");
PollTransferSignalsResponse receiverSignals = session.poll(TransferRole.RECEIVER, 0);
PollTransferSignalsResponse senderSignals = session.poll(TransferRole.SENDER, 0);
assertThat(receiverSignals.items())
.extracting(TransferSignalEnvelope::type, TransferSignalEnvelope::payload)
.containsExactly(org.assertj.core.groups.Tuple.tuple("offer", "{\"sdp\":\"demo-offer\"}"));
assertThat(senderSignals.items())
.extracting(TransferSignalEnvelope::type, TransferSignalEnvelope::payload)
.containsExactly(org.assertj.core.groups.Tuple.tuple("answer", "{\"sdp\":\"demo-answer\"}"));
}
}