package com.yoyuzh.files; import com.yoyuzh.auth.User; import com.yoyuzh.common.BusinessException; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.files.storage.FileContentStorage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.Clock; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class UploadSessionServiceTest { @Mock private UploadSessionRepository uploadSessionRepository; @Mock private StoredFileRepository storedFileRepository; @Mock private FileService fileService; @Mock private FileContentStorage fileContentStorage; private UploadSessionService uploadSessionService; @BeforeEach void setUp() { FileStorageProperties properties = new FileStorageProperties(); properties.setMaxFileSize(500L * 1024 * 1024); uploadSessionService = new UploadSessionService( uploadSessionRepository, storedFileRepository, fileService, fileContentStorage, properties, Clock.fixed(Instant.parse("2026-04-08T06:00:00Z"), ZoneOffset.UTC) ); } @Test void shouldCreateUploadSessionWithoutChangingLegacyUploadPath() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(false); when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> { UploadSession session = invocation.getArgument(0); session.setId(100L); return session; }); UploadSession session = uploadSessionService.createSession( user, new UploadSessionCreateCommand("/docs", "movie.mp4", "video/mp4", 20L * 1024 * 1024) ); assertThat(session.getSessionId()).isNotBlank(); assertThat(session.getObjectKey()).startsWith("blobs/"); assertThat(session.getStatus()).isEqualTo(UploadSessionStatus.CREATED); assertThat(session.getChunkSize()).isEqualTo(8L * 1024 * 1024); assertThat(session.getChunkCount()).isEqualTo(3); assertThat(session.getExpiresAt()).isEqualTo(LocalDateTime.of(2026, 4, 9, 6, 0)); } @Test void shouldOnlyReturnSessionOwnedByCurrentUser() { User user = createUser(7L); UploadSession session = new UploadSession(); session.setSessionId("session-1"); session.setUser(user); session.setStatus(UploadSessionStatus.CREATED); when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) .thenReturn(Optional.of(session)); UploadSession result = uploadSessionService.getOwnedSession(user, "session-1"); assertThat(result).isSameAs(session); } @Test void shouldRejectDuplicateTargetWhenCreatingSession() { User user = createUser(7L); when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "movie.mp4")).thenReturn(true); assertThatThrownBy(() -> uploadSessionService.createSession( user, new UploadSessionCreateCommand("/docs", "movie.mp4", "video/mp4", 20L) )).isInstanceOf(BusinessException.class); } @Test void shouldCompleteOwnedSessionThroughLegacyFileCommitPath() { User user = createUser(7L); UploadSession session = createSession(user); when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) .thenReturn(Optional.of(session)); when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> invocation.getArgument(0)); UploadSession result = uploadSessionService.completeOwnedSession(user, "session-1"); assertThat(result.getStatus()).isEqualTo(UploadSessionStatus.COMPLETED); assertThat(result.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 4, 8, 6, 0)); ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(CompleteUploadRequest.class); verify(fileService).completeUpload(eq(user), requestCaptor.capture()); assertThat(requestCaptor.getValue().path()).isEqualTo("/docs"); assertThat(requestCaptor.getValue().filename()).isEqualTo("movie.mp4"); assertThat(requestCaptor.getValue().storageName()).isEqualTo("blobs/session-1"); assertThat(requestCaptor.getValue().contentType()).isEqualTo("video/mp4"); assertThat(requestCaptor.getValue().size()).isEqualTo(20L); } @Test void shouldRejectCompletingCancelledSession() { User user = createUser(7L); UploadSession session = createSession(user); session.setStatus(UploadSessionStatus.CANCELLED); when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) .thenReturn(Optional.of(session)); assertThatThrownBy(() -> uploadSessionService.completeOwnedSession(user, "session-1")) .isInstanceOf(BusinessException.class); } @Test void shouldRecordUploadedPartAndMoveSessionToUploading() { User user = createUser(7L); UploadSession session = createSession(user); session.setChunkCount(3); when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) .thenReturn(Optional.of(session)); when(uploadSessionRepository.save(any(UploadSession.class))).thenAnswer(invocation -> invocation.getArgument(0)); UploadSession result = uploadSessionService.recordUploadedPart( user, "session-1", 1, new UploadSessionPartCommand("etag-1", 8L * 1024 * 1024) ); assertThat(result.getStatus()).isEqualTo(UploadSessionStatus.UPLOADING); assertThat(result.getUploadedPartsJson()).contains("\"partIndex\":1"); assertThat(result.getUploadedPartsJson()).contains("\"etag\":\"etag-1\""); assertThat(result.getUploadedPartsJson()).contains("\"size\":8388608"); UploadSession secondResult = uploadSessionService.recordUploadedPart( user, "session-1", 2, new UploadSessionPartCommand("etag-2", 4L) ); assertThat(secondResult.getUploadedPartsJson()).contains("\"partIndex\":1"); assertThat(secondResult.getUploadedPartsJson()).contains("\"partIndex\":2"); assertThat(secondResult.getUploadedPartsJson()).contains("\"etag\":\"etag-2\""); } @Test void shouldRejectUploadedPartOutsideSessionRange() { User user = createUser(7L); UploadSession session = createSession(user); session.setChunkCount(3); when(uploadSessionRepository.findBySessionIdAndUserId("session-1", 7L)) .thenReturn(Optional.of(session)); assertThatThrownBy(() -> uploadSessionService.recordUploadedPart( user, "session-1", 3, new UploadSessionPartCommand("etag-3", 1L) )).isInstanceOf(BusinessException.class); } @Test void shouldExpireUnfinishedSessionsAndDeleteTemporaryBlobs() { User user = createUser(7L); UploadSession session = createSession(user); session.setStatus(UploadSessionStatus.UPLOADING); session.setObjectKey("blobs/expired-session"); session.setExpiresAt(LocalDateTime.of(2026, 4, 8, 5, 0)); when(uploadSessionRepository.findByStatusInAndExpiresAtBefore(anyList(), eq(LocalDateTime.of(2026, 4, 8, 6, 0)))) .thenReturn(List.of(session)); int expiredCount = uploadSessionService.pruneExpiredSessions(); assertThat(expiredCount).isEqualTo(1); assertThat(session.getStatus()).isEqualTo(UploadSessionStatus.EXPIRED); assertThat(session.getUpdatedAt()).isEqualTo(LocalDateTime.of(2026, 4, 8, 6, 0)); verify(fileContentStorage).deleteBlob("blobs/expired-session"); verify(uploadSessionRepository).saveAll(List.of(session)); } private User createUser(Long id) { User user = new User(); user.setId(id); user.setUsername("user-" + id); user.setEmail("user-" + id + "@example.com"); user.setPasswordHash("encoded"); user.setCreatedAt(LocalDateTime.now()); return user; } private UploadSession createSession(User user) { UploadSession session = new UploadSession(); session.setSessionId("session-1"); session.setUser(user); session.setTargetPath("/docs"); session.setFilename("movie.mp4"); session.setContentType("video/mp4"); session.setSize(20L); session.setObjectKey("blobs/session-1"); session.setChunkSize(8L * 1024 * 1024); session.setChunkCount(1); session.setUploadedPartsJson("[]"); session.setStatus(UploadSessionStatus.CREATED); session.setCreatedAt(LocalDateTime.of(2026, 4, 8, 6, 0)); session.setUpdatedAt(LocalDateTime.of(2026, 4, 8, 6, 0)); session.setExpiresAt(LocalDateTime.of(2026, 4, 9, 6, 0)); return session; } }