feat(files): add v2 task and metadata workflows

This commit is contained in:
yoyuzh
2026-04-09 00:42:41 +08:00
parent c5362ebe31
commit 977eb60b17
60 changed files with 5218 additions and 72 deletions

View File

@@ -0,0 +1,77 @@
package com.yoyuzh.api.v2.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.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
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.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:file_events_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-events"
}
)
@AutoConfigureMockMvc
class FileEventsV2ControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
User user = new User();
user.setUsername("alice");
user.setEmail("alice@example.com");
user.setPhoneNumber("13800138000");
user.setPasswordHash("encoded-password");
user.setCreatedAt(LocalDateTime.now());
userRepository.save(user);
}
@Test
void shouldRequireAuthenticationForFileEventStream() throws Exception {
mockMvc.perform(get("/api/v2/files/events").with(anonymous()))
.andExpect(status().isUnauthorized());
}
@Test
void shouldOpenStreamAndSendReadyEvent() throws Exception {
var result = mockMvc.perform(get("/api/v2/files/events")
.with(user("alice"))
.param("path", "/docs")
.header("X-Yoyuzh-Client-Id", "tab-1"))
.andExpect(request().asyncStarted())
.andReturn();
String body = result.getResponse().getContentAsString();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat(result.getResponse().getContentType()).startsWith("text/event-stream");
assertThat(body).contains("READY");
assertThat(body).contains("/docs");
assertThat(body).contains("tab-1");
}
}

View File

@@ -0,0 +1,133 @@
package com.yoyuzh.api.v2.files;
import com.yoyuzh.api.v2.ApiV2ExceptionHandler;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.files.FileMetadataResponse;
import com.yoyuzh.files.FileSearchQuery;
import com.yoyuzh.files.FileSearchService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class FileSearchV2ControllerTest {
private FileSearchService fileSearchService;
private CustomUserDetailsService userDetailsService;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
fileSearchService = mock(FileSearchService.class);
userDetailsService = mock(CustomUserDetailsService.class);
mockMvc = MockMvcBuilders.standaloneSetup(new FileSearchV2Controller(fileSearchService, userDetailsService))
.setControllerAdvice(new ApiV2ExceptionHandler())
.setCustomArgumentResolvers(authenticationPrincipalResolver())
.build();
}
@Test
void shouldSearchFilesWithV2Envelope() throws Exception {
User user = createUser(7L);
when(userDetailsService.loadDomainUser("alice")).thenReturn(user);
when(fileSearchService.search(eq(user), any(FileSearchQuery.class))).thenReturn(new PageResponse<>(
List.of(new FileMetadataResponse(
10L,
"notes.txt",
"/docs",
5L,
"text/plain",
false,
LocalDateTime.of(2026, 4, 8, 10, 0)
)),
1,
0,
20
));
mockMvc.perform(get("/api/v2/files/search")
.with(user(userDetails()))
.accept(MediaType.APPLICATION_JSON)
.param("name", "note")
.param("type", "file")
.param("sizeGte", "1")
.param("sizeLte", "100")
.param("createdGte", "2026-04-08T08:00:00")
.param("createdLte", "2026-04-08T12:00:00")
.param("updatedGte", "2026-04-08T09:00:00")
.param("updatedLte", "2026-04-08T18:00:00")
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value(1))
.andExpect(jsonPath("$.data.items[0].filename").value("notes.txt"));
}
@Test
void shouldRejectUnsupportedTypeFilter() throws Exception {
mockMvc.perform(get("/api/v2/files/search")
.with(user(userDetails()))
.param("type", "image"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(2400))
.andExpect(jsonPath("$.msg").value("文件类型筛选只支持 file 或 directory"));
}
private UserDetails userDetails() {
return org.springframework.security.core.userdetails.User
.withUsername("alice")
.password("encoded")
.authorities("ROLE_USER")
.build();
}
private HandlerMethodArgumentResolver authenticationPrincipalResolver() {
UserDetails userDetails = userDetails();
return new HandlerMethodArgumentResolver() {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticationPrincipal.class)
&& UserDetails.class.isAssignableFrom(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
return userDetails;
}
};
}
private User createUser(Long id) {
User user = new User();
user.setId(id);
user.setUsername("alice");
user.setEmail("alice@example.com");
return user;
}
}

View File

@@ -0,0 +1,299 @@
package com.yoyuzh.api.v2.shares;
import com.jayway.jsonpath.JsonPath;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.files.FileBlob;
import com.yoyuzh.files.FileBlobRepository;
import com.yoyuzh.files.FileShareLink;
import com.yoyuzh.files.FileShareLinkRepository;
import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository;
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 java.util.Comparator;
import static org.hamcrest.Matchers.nullValue;
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.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:share_v2_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-share-v2"
}
)
@AutoConfigureMockMvc
class ShareV2ControllerIntegrationTest {
private static final Path STORAGE_ROOT = Path.of("./target/test-storage-share-v2").toAbsolutePath().normalize();
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private StoredFileRepository storedFileRepository;
@Autowired
private FileBlobRepository fileBlobRepository;
@Autowired
private FileShareLinkRepository fileShareLinkRepository;
private Long sharedFileId;
@BeforeEach
void setUp() throws Exception {
fileShareLinkRepository.deleteAll();
storedFileRepository.deleteAll();
fileBlobRepository.deleteAll();
userRepository.deleteAll();
if (Files.exists(STORAGE_ROOT)) {
try (var paths = Files.walk(STORAGE_ROOT)) {
paths.sorted(Comparator.reverseOrder()).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());
userRepository.save(recipient);
FileBlob blob = new FileBlob();
blob.setObjectKey("blobs/share-v2-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);
sharedFileId = storedFileRepository.save(file).getId();
Path blobPath = STORAGE_ROOT.resolve("blobs").resolve("share-v2-notes");
Files.createDirectories(blobPath.getParent());
Files.writeString(blobPath, "hello", StandardCharsets.UTF_8);
}
@Test
void shouldCreateReadVerifyImportAndDeleteOwnV2Share() throws Exception {
String createResponse = mockMvc.perform(post("/api/v2/shares")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"password": "Share123",
"shareName": "course-share",
"allowImport": true,
"allowDownload": true
}
""".formatted(sharedFileId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.token").isNotEmpty())
.andExpect(jsonPath("$.data.shareName").value("course-share"))
.andExpect(jsonPath("$.data.passwordRequired").value(true))
.andExpect(jsonPath("$.data.file.filename").value("notes.txt"))
.andReturn()
.getResponse()
.getContentAsString();
String token = JsonPath.read(createResponse, "$.data.token");
Long shareId = ((Number) JsonPath.read(createResponse, "$.data.id")).longValue();
mockMvc.perform(get("/api/v2/shares/{token}", token).with(anonymous()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.passwordRequired").value(true))
.andExpect(jsonPath("$.data.passwordVerified").value(false))
.andExpect(jsonPath("$.data.file").value(nullValue()));
mockMvc.perform(post("/api/v2/shares/{token}/verify-password", token)
.with(anonymous())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"password": "WrongPass1!"
}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(2400));
mockMvc.perform(post("/api/v2/shares/{token}/verify-password", token)
.with(anonymous())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"password": "Share123"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.passwordVerified").value(true))
.andExpect(jsonPath("$.data.file.filename").value("notes.txt"));
mockMvc.perform(post("/api/v2/shares/{token}/import", token)
.with(user("bob"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"path": "/download",
"password": "Share123"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.filename").value("notes.txt"))
.andExpect(jsonPath("$.data.path").value("/download"));
mockMvc.perform(get("/api/v2/shares/mine")
.with(user("alice"))
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].id").value(shareId))
.andExpect(jsonPath("$.data.items[0].file.filename").value("notes.txt"));
mockMvc.perform(delete("/api/v2/shares/{id}", shareId)
.with(user("alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(get("/api/v2/shares/mine")
.with(user("alice"))
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(0));
}
@Test
void shouldRejectDisabledOrExpiredV2ShareImports() throws Exception {
String disabledResponse = mockMvc.perform(post("/api/v2/shares")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"allowImport": false,
"allowDownload": true
}
""".formatted(sharedFileId)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
String disabledToken = JsonPath.read(disabledResponse, "$.data.token");
mockMvc.perform(post("/api/v2/shares/{token}/import", disabledToken)
.with(user("bob"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"path": "/download"
}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(2403));
String expiringResponse = mockMvc.perform(post("/api/v2/shares")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"allowImport": true,
"allowDownload": true
}
""".formatted(sharedFileId)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
String expiringToken = JsonPath.read(expiringResponse, "$.data.token");
FileShareLink expiringShare = fileShareLinkRepository.findByToken(expiringToken).orElseThrow();
expiringShare.setExpiresAt(LocalDateTime.now().minusMinutes(1));
fileShareLinkRepository.save(expiringShare);
mockMvc.perform(post("/api/v2/shares/{token}/import", expiringToken)
.with(user("bob"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"path": "/download"
}
"""))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(2404));
}
@Test
void shouldDenyDeletingOtherUsersShare() throws Exception {
String createResponse = mockMvc.perform(post("/api/v2/shares")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d
}
""".formatted(sharedFileId)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
Long shareId = ((Number) JsonPath.read(createResponse, "$.data.id")).longValue();
mockMvc.perform(delete("/api/v2/shares/{id}", shareId)
.with(user("bob")))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(2404));
}
}

View File

@@ -0,0 +1,277 @@
package com.yoyuzh.api.v2.tasks;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.JsonPath;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.auth.User;
import com.yoyuzh.auth.UserRepository;
import com.yoyuzh.files.BackgroundTask;
import com.yoyuzh.files.BackgroundTaskRepository;
import com.yoyuzh.files.BackgroundTaskStatus;
import com.yoyuzh.files.BackgroundTaskType;
import com.yoyuzh.files.StoredFile;
import com.yoyuzh.files.StoredFileRepository;
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.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
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.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:background_task_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-background-task"
}
)
@AutoConfigureMockMvc
class BackgroundTaskV2ControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private UserRepository userRepository;
@Autowired
private BackgroundTaskRepository backgroundTaskRepository;
@Autowired
private StoredFileRepository storedFileRepository;
@Autowired
private ObjectMapper objectMapper;
private Long archiveDirectoryId;
private Long archiveFileId;
private Long extractFileId;
private Long mediaFileId;
private Long foreignFileId;
private Long deletedFileId;
@BeforeEach
void setUp() {
backgroundTaskRepository.deleteAll();
storedFileRepository.deleteAll();
userRepository.deleteAll();
User alice = new User();
alice.setUsername("alice");
alice.setEmail("alice@example.com");
alice.setPhoneNumber("13800138000");
alice.setPasswordHash("encoded-password");
alice.setCreatedAt(LocalDateTime.now());
userRepository.save(alice);
User bob = new User();
bob.setUsername("bob");
bob.setEmail("bob@example.com");
bob.setPhoneNumber("13800138001");
bob.setPasswordHash("encoded-password");
bob.setCreatedAt(LocalDateTime.now());
bob = userRepository.save(bob);
archiveDirectoryId = storedFileRepository.save(createFile(alice, "/docs", "archive", true, null, 0L, null)).getId();
archiveFileId = storedFileRepository.save(createFile(alice, "/docs", "archive-source.txt", false, "text/plain", 12L, null)).getId();
extractFileId = storedFileRepository.save(createFile(alice, "/docs", "extract.zip", false, "application/zip", 32L, null)).getId();
mediaFileId = storedFileRepository.save(createFile(alice, "/docs", "media.png", false, "image/png", 24L, null)).getId();
foreignFileId = storedFileRepository.save(createFile(bob, "/docs", "foreign.zip", false, "application/zip", 32L, null)).getId();
deletedFileId = storedFileRepository.save(createFile(alice, "/docs", "deleted.zip", false, "application/zip", 32L, LocalDateTime.now())).getId();
}
@Test
void shouldRequireAuthenticationForTaskEndpoints() throws Exception {
mockMvc.perform(get("/api/v2/tasks").with(anonymous()))
.andExpect(status().isUnauthorized());
mockMvc.perform(post("/api/v2/tasks/archive")
.with(anonymous())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": 1,
"path": "/docs"
}
"""))
.andExpect(status().isUnauthorized());
}
@Test
void shouldQueueListGetAndCancelOwnedTasks() throws Exception {
String archiveResponse = mockMvc.perform(post("/api/v2/tasks/archive")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"path": "/docs/archive",
"correlationId": "archive-1"
}
""".formatted(archiveDirectoryId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.type").value("ARCHIVE"))
.andExpect(jsonPath("$.data.status").value("QUEUED"))
.andExpect(jsonPath("$.data.publicStateJson", containsString("\"fileId\":" + archiveDirectoryId)))
.andExpect(jsonPath("$.data.publicStateJson", containsString("\"path\":\"/docs/archive\"")))
.andExpect(jsonPath("$.data.publicStateJson", containsString("\"directory\":true")))
.andReturn()
.getResponse()
.getContentAsString();
String extractResponse = mockMvc.perform(post("/api/v2/tasks/extract")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"path": "/docs/extract.zip"
}
""".formatted(extractFileId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.type").value("EXTRACT"))
.andExpect(jsonPath("$.data.status").value("QUEUED"))
.andReturn()
.getResponse()
.getContentAsString();
String mediaResponse = mockMvc.perform(post("/api/v2/tasks/media-metadata")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"path": "/docs/media.png"
}
""".formatted(mediaFileId)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.type").value("MEDIA_META"))
.andExpect(jsonPath("$.data.status").value("QUEUED"))
.andReturn()
.getResponse()
.getContentAsString();
Long archiveId = ((Number) JsonPath.read(archiveResponse, "$.data.id")).longValue();
Long extractId = ((Number) JsonPath.read(extractResponse, "$.data.id")).longValue();
Long mediaId = ((Number) JsonPath.read(mediaResponse, "$.data.id")).longValue();
mockMvc.perform(get("/api/v2/tasks")
.with(user("alice"))
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value(3))
.andExpect(jsonPath("$.data.items[0].id").value(mediaId));
mockMvc.perform(get("/api/v2/tasks/{id}", archiveId).with(user("alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.id").value(archiveId))
.andExpect(jsonPath("$.data.privateStateJson").doesNotExist());
mockMvc.perform(delete("/api/v2/tasks/{id}", extractId).with(user("alice")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("CANCELLED"));
BackgroundTask cancelled = backgroundTaskRepository.findById(extractId).orElseThrow();
assertThat(cancelled.getStatus()).isEqualTo(BackgroundTaskStatus.CANCELLED);
assertThat(cancelled.getFinishedAt()).isNotNull();
}
@Test
void shouldRejectOtherUsersTaskAccess() throws Exception {
String response = mockMvc.perform(post("/api/v2/tasks/archive")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"path": "/docs/archive-source.txt"
}
""".formatted(archiveFileId)))
.andExpect(status().isOk())
.andReturn()
.getResponse()
.getContentAsString();
Long taskId = ((Number) JsonPath.read(response, "$.data.id")).longValue();
mockMvc.perform(get("/api/v2/tasks/{id}", taskId).with(user("bob")))
.andExpect(status().isNotFound());
mockMvc.perform(delete("/api/v2/tasks/{id}", taskId).with(user("bob")))
.andExpect(status().isNotFound());
}
@Test
void shouldRejectInvalidTaskTargetsBeforeQueueing() throws Exception {
mockMvc.perform(post("/api/v2/tasks/archive")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"path": "/docs/foreign.zip"
}
""".formatted(foreignFileId)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(2404));
mockMvc.perform(post("/api/v2/tasks/archive")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"path": "/docs/deleted.zip"
}
""".formatted(deletedFileId)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(2404));
mockMvc.perform(post("/api/v2/tasks/archive")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"fileId": %d,
"path": "/docs/client-path.zip"
}
""".formatted(extractFileId)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(2400));
}
private StoredFile createFile(User user,
String path,
String filename,
boolean directory,
String contentType,
Long size,
LocalDateTime deletedAt) {
StoredFile file = new StoredFile();
file.setUser(user);
file.setPath(path);
file.setFilename(filename);
file.setDirectory(directory);
file.setContentType(contentType);
file.setSize(size);
file.setDeletedAt(deletedAt);
return file;
}
}

View File

@@ -0,0 +1,246 @@
package com.yoyuzh.files;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.auth.User;
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.util.List;
import java.util.Map;
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.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BackgroundTaskServiceTest {
@Mock
private BackgroundTaskRepository backgroundTaskRepository;
@Mock
private StoredFileRepository storedFileRepository;
private BackgroundTaskService backgroundTaskService;
@BeforeEach
void setUp() {
backgroundTaskService = new BackgroundTaskService(backgroundTaskRepository, storedFileRepository, new ObjectMapper());
}
@Test
void shouldRejectTaskCreationForForeignFile() {
User user = createUser(7L);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(99L, 7L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
user,
BackgroundTaskType.ARCHIVE,
99L,
"/docs/foreign.txt",
null
)).isInstanceOf(ApiV2Exception.class)
.hasMessage("file not found");
}
@Test
void shouldRejectTaskCreationForDeletedFile() {
User user = createUser(7L);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(100L, 7L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
user,
BackgroundTaskType.ARCHIVE,
100L,
"/docs/deleted.txt",
null
)).isInstanceOf(ApiV2Exception.class)
.hasMessage("file not found");
}
@Test
void shouldRejectTaskCreationWhenRequestedPathDoesNotMatchFile() {
User user = createUser(7L);
StoredFile file = createStoredFile(11L, user, "/docs", "real.txt", false, "text/plain", 3L);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(11L, 7L)).thenReturn(Optional.of(file));
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
user,
BackgroundTaskType.ARCHIVE,
11L,
"/docs/fake.txt",
null
)).isInstanceOf(ApiV2Exception.class)
.hasMessage("task path does not match file path");
}
@Test
void shouldRejectExtractTaskForDirectory() {
User user = createUser(7L);
StoredFile directory = createStoredFile(12L, user, "/", "bundle", true, null, 0L);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(12L, 7L)).thenReturn(Optional.of(directory));
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
user,
BackgroundTaskType.EXTRACT,
12L,
"/bundle",
null
)).isInstanceOf(ApiV2Exception.class)
.hasMessage("task target type is not supported");
}
@Test
void shouldRejectMediaMetadataTaskForNonMediaFile() {
User user = createUser(7L);
StoredFile file = createStoredFile(13L, user, "/docs", "notes.txt", false, "text/plain", 9L);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(13L, 7L)).thenReturn(Optional.of(file));
assertThatThrownBy(() -> backgroundTaskService.createQueuedFileTask(
user,
BackgroundTaskType.MEDIA_META,
13L,
"/docs/notes.txt",
null
)).isInstanceOf(ApiV2Exception.class)
.hasMessage("media metadata task only supports media files");
}
@Test
void shouldCreateTaskStateFromServerFilePath() {
User user = createUser(7L);
StoredFile file = createStoredFile(14L, user, "/docs", "photo.png", false, "image/png", 15L);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(14L, 7L)).thenReturn(Optional.of(file));
when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
BackgroundTask task = backgroundTaskService.createQueuedFileTask(
user,
BackgroundTaskType.MEDIA_META,
14L,
"/docs/photo.png",
"media-1"
);
assertThat(task.getPublicStateJson()).contains("\"fileId\":14");
assertThat(task.getPublicStateJson()).contains("\"path\":\"/docs/photo.png\"");
assertThat(task.getPublicStateJson()).contains("\"filename\":\"photo.png\"");
assertThat(task.getPublicStateJson()).contains("\"directory\":false");
assertThat(task.getPublicStateJson()).contains("\"contentType\":\"image/png\"");
assertThat(task.getPublicStateJson()).contains("\"size\":15");
assertThat(task.getPrivateStateJson()).contains("\"taskType\":\"MEDIA_META\"");
}
@Test
void shouldClaimQueuedTaskOnlyWhenRepositoryTransitionSucceeds() {
BackgroundTask task = createTask(1L, BackgroundTaskStatus.RUNNING);
when(backgroundTaskRepository.claimQueuedTask(
eq(1L),
eq(BackgroundTaskStatus.QUEUED),
eq(BackgroundTaskStatus.RUNNING),
any()
)).thenReturn(1);
when(backgroundTaskRepository.findById(1L)).thenReturn(Optional.of(task));
Optional<BackgroundTask> result = backgroundTaskService.claimQueuedTask(1L);
assertThat(result).containsSame(task);
}
@Test
void shouldNotClaimTaskWhenRepositoryTransitionWasSkipped() {
when(backgroundTaskRepository.claimQueuedTask(
eq(2L),
eq(BackgroundTaskStatus.QUEUED),
eq(BackgroundTaskStatus.RUNNING),
any()
)).thenReturn(0);
Optional<BackgroundTask> result = backgroundTaskService.claimQueuedTask(2L);
assertThat(result).isEmpty();
}
@Test
void shouldCompleteRunningWorkerTaskAndMergePublicState() {
BackgroundTask task = createTask(3L, BackgroundTaskStatus.RUNNING);
task.setPublicStateJson("{\"fileId\":11}");
when(backgroundTaskRepository.findById(3L)).thenReturn(Optional.of(task));
when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
BackgroundTask result = backgroundTaskService.markWorkerTaskCompleted(3L, Map.of("worker", "noop"));
assertThat(result.getStatus()).isEqualTo(BackgroundTaskStatus.COMPLETED);
assertThat(result.getFinishedAt()).isNotNull();
assertThat(result.getErrorMessage()).isNull();
assertThat(result.getPublicStateJson()).contains("\"fileId\":11");
assertThat(result.getPublicStateJson()).contains("\"worker\":\"noop\"");
}
@Test
void shouldRecordWorkerFailureMessage() {
BackgroundTask task = createTask(4L, BackgroundTaskStatus.RUNNING);
when(backgroundTaskRepository.findById(4L)).thenReturn(Optional.of(task));
when(backgroundTaskRepository.save(any(BackgroundTask.class))).thenAnswer(invocation -> invocation.getArgument(0));
BackgroundTask result = backgroundTaskService.markWorkerTaskFailed(4L, "media parser unavailable");
assertThat(result.getStatus()).isEqualTo(BackgroundTaskStatus.FAILED);
assertThat(result.getFinishedAt()).isNotNull();
assertThat(result.getErrorMessage()).isEqualTo("media parser unavailable");
}
@Test
void shouldFindQueuedTaskIdsInCreatedOrderLimit() {
BackgroundTask first = createTask(5L, BackgroundTaskStatus.QUEUED);
BackgroundTask second = createTask(6L, BackgroundTaskStatus.QUEUED);
when(backgroundTaskRepository.findByStatusOrderByCreatedAtAsc(eq(BackgroundTaskStatus.QUEUED), any()))
.thenReturn(List.of(first, second));
List<Long> result = backgroundTaskService.findQueuedTaskIds(2);
assertThat(result).containsExactly(5L, 6L);
}
private BackgroundTask createTask(Long id, BackgroundTaskStatus status) {
BackgroundTask task = new BackgroundTask();
task.setId(id);
task.setType(BackgroundTaskType.MEDIA_META);
task.setStatus(status);
task.setUserId(7L);
task.setPublicStateJson("{}");
task.setPrivateStateJson("{}");
return task;
}
private User createUser(Long id) {
User user = new User();
user.setId(id);
user.setUsername("alice");
return user;
}
private StoredFile createStoredFile(Long id,
User user,
String path,
String filename,
boolean directory,
String contentType,
Long size) {
StoredFile file = new StoredFile();
file.setId(id);
file.setUser(user);
file.setPath(path);
file.setFilename(filename);
file.setDirectory(directory);
file.setContentType(contentType);
file.setSize(size);
return file;
}
}

View File

@@ -0,0 +1,83 @@
package com.yoyuzh.files;
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.util.List;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class BackgroundTaskWorkerTest {
@Mock
private BackgroundTaskService backgroundTaskService;
@Mock
private BackgroundTaskHandler backgroundTaskHandler;
private BackgroundTaskWorker backgroundTaskWorker;
@BeforeEach
void setUp() {
backgroundTaskWorker = new BackgroundTaskWorker(backgroundTaskService, List.of(backgroundTaskHandler));
}
@Test
void shouldClaimAndCompleteQueuedTaskThroughNoopHandler() {
BackgroundTask task = createTask(1L, BackgroundTaskType.ARCHIVE, BackgroundTaskStatus.RUNNING);
when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(1L));
when(backgroundTaskService.claimQueuedTask(1L)).thenReturn(Optional.of(task));
when(backgroundTaskHandler.supports(BackgroundTaskType.ARCHIVE)).thenReturn(true);
when(backgroundTaskHandler.handle(task)).thenReturn(new BackgroundTaskHandlerResult(Map.of("worker", "noop")));
int processedCount = backgroundTaskWorker.processQueuedTasks(5);
assertThat(processedCount).isEqualTo(1);
verify(backgroundTaskHandler).handle(task);
verify(backgroundTaskService).markWorkerTaskCompleted(1L, Map.of("worker", "noop"));
}
@Test
void shouldSkipTaskThatWasNotClaimed() {
when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(1L));
when(backgroundTaskService.claimQueuedTask(1L)).thenReturn(Optional.empty());
int processedCount = backgroundTaskWorker.processQueuedTasks(5);
assertThat(processedCount).isZero();
verify(backgroundTaskHandler, never()).handle(org.mockito.ArgumentMatchers.any());
}
@Test
void shouldMarkTaskFailedWhenHandlerThrows() {
BackgroundTask task = createTask(2L, BackgroundTaskType.MEDIA_META, BackgroundTaskStatus.RUNNING);
when(backgroundTaskService.findQueuedTaskIds(5)).thenReturn(List.of(2L));
when(backgroundTaskService.claimQueuedTask(2L)).thenReturn(Optional.of(task));
when(backgroundTaskHandler.supports(BackgroundTaskType.MEDIA_META)).thenReturn(true);
when(backgroundTaskHandler.handle(task)).thenThrow(new IllegalStateException("media parser unavailable"));
int processedCount = backgroundTaskWorker.processQueuedTasks(5);
assertThat(processedCount).isEqualTo(1);
verify(backgroundTaskService).markWorkerTaskFailed(2L, "media parser unavailable");
}
private BackgroundTask createTask(Long id, BackgroundTaskType type, BackgroundTaskStatus status) {
BackgroundTask task = new BackgroundTask();
task.setId(id);
task.setType(type);
task.setStatus(status);
task.setUserId(7L);
task.setPublicStateJson("{}");
task.setPrivateStateJson("{}");
return task;
}
}

View File

@@ -0,0 +1,106 @@
package com.yoyuzh.files;
import com.yoyuzh.PortalBackendApplication;
import com.yoyuzh.admin.AdminMetricsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.files.storage.FileContentStorage;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@SpringBootTest(
classes = PortalBackendApplication.class,
properties = {
"spring.datasource.url=jdbc:h2:mem:file_events_service_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-events-service"
}
)
class FileEventPersistenceIntegrationTest {
@Autowired
private FileService fileService;
@Autowired
private FileEventRepository fileEventRepository;
@MockBean
private StoredFileRepository storedFileRepository;
@MockBean
private FileBlobRepository fileBlobRepository;
@MockBean
private FileEntityRepository fileEntityRepository;
@MockBean
private StoredFileEntityRepository storedFileEntityRepository;
@MockBean
private FileContentStorage fileContentStorage;
@MockBean
private FileShareLinkRepository fileShareLinkRepository;
@MockBean
private AdminMetricsService adminMetricsService;
@MockBean
private StoragePolicyService storagePolicyService;
@BeforeEach
void setUp() {
fileEventRepository.deleteAll();
}
@Test
void shouldPersistRenameEventWhenFileChanges() {
User user = new User();
user.setId(7L);
user.setUsername("alice");
user.setEmail("alice@example.com");
user.setCreatedAt(LocalDateTime.now());
StoredFile file = new StoredFile();
file.setId(10L);
file.setUser(user);
file.setFilename("notes.txt");
file.setPath("/docs");
file.setContentType("text/plain");
file.setSize(5L);
file.setDirectory(false);
file.setCreatedAt(LocalDateTime.now());
when(storedFileRepository.findDetailedById(10L)).thenReturn(Optional.of(file));
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "paper.txt")).thenReturn(false);
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0));
FileMetadataResponse response = fileService.rename(user, 10L, "paper.txt");
assertThat(response.filename()).isEqualTo("paper.txt");
assertThat(fileEventRepository.count()).isEqualTo(1L);
FileEvent event = fileEventRepository.findAll().get(0);
assertThat(event.getEventType()).isEqualTo(FileEventType.RENAMED);
assertThat(event.getFileId()).isEqualTo(10L);
assertThat(event.getFromPath()).isEqualTo("/docs/notes.txt");
assertThat(event.getToPath()).isEqualTo("/docs/paper.txt");
assertThat(event.getPayloadJson()).contains("\"action\":\"RENAMED\"");
assertThat(event.getPayloadJson()).contains("\"filename\":\"paper.txt\"");
}
}

View File

@@ -0,0 +1,147 @@
package com.yoyuzh.files;
import com.yoyuzh.api.v2.ApiV2Exception;
import com.yoyuzh.auth.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FileSearchServiceTest {
@Mock
private StoredFileRepository storedFileRepository;
private FileSearchService fileSearchService;
@BeforeEach
void setUp() {
fileSearchService = new FileSearchService(storedFileRepository);
}
@Test
void shouldSearchOwnedActiveFiles() {
User user = createUser(7L);
StoredFile file = createFile(10L, user, "/docs", "notes.txt", false);
LocalDateTime createdGte = LocalDateTime.of(2026, 4, 8, 8, 0);
LocalDateTime createdLte = LocalDateTime.of(2026, 4, 8, 12, 0);
LocalDateTime updatedGte = LocalDateTime.of(2026, 4, 8, 9, 0);
LocalDateTime updatedLte = LocalDateTime.of(2026, 4, 8, 18, 0);
when(storedFileRepository.searchUserFiles(
eq(7L),
eq("note"),
eq(false),
eq(1L),
eq(100L),
eq(createdGte),
eq(createdLte),
eq(updatedGte),
eq(updatedLte),
eq(PageRequest.of(0, 20))
)).thenReturn(new PageImpl<>(List.of(file), PageRequest.of(0, 20), 1));
var response = fileSearchService.search(user, new FileSearchQuery(
" note ",
false,
1L,
100L,
createdGte,
createdLte,
updatedGte,
updatedLte,
0,
20
));
assertThat(response.total()).isEqualTo(1);
assertThat(response.items()).hasSize(1);
assertThat(response.items().get(0).filename()).isEqualTo("notes.txt");
assertThat(response.items().get(0).path()).isEqualTo("/docs");
}
@Test
void shouldReturnDirectoryLogicalPathForDirectoryResults() {
User user = createUser(7L);
StoredFile directory = createFile(11L, user, "/docs", "archive", true);
when(storedFileRepository.searchUserFiles(
eq(7L),
eq(null),
eq(true),
eq(null),
eq(null),
eq(null),
eq(null),
eq(null),
eq(null),
eq(PageRequest.of(0, 20))
)).thenReturn(new PageImpl<>(List.of(directory), PageRequest.of(0, 20), 1));
var response = fileSearchService.search(user, new FileSearchQuery(
null,
true,
null,
null,
null,
null,
null,
null,
0,
20
));
assertThat(response.items().get(0).path()).isEqualTo("/docs/archive");
}
@Test
void shouldRejectInvalidSearchRange() {
User user = createUser(7L);
assertThatThrownBy(() -> fileSearchService.search(user, new FileSearchQuery(
null,
null,
100L,
1L,
null,
null,
null,
null,
0,
20
))).isInstanceOf(ApiV2Exception.class)
.hasMessageContaining("文件大小范围不合法");
}
private User createUser(Long id) {
User user = new User();
user.setId(id);
user.setUsername("user-" + id);
user.setEmail("user-" + id + "@example.com");
return user;
}
private StoredFile createFile(Long id, User user, String path, String filename, boolean directory) {
StoredFile file = new StoredFile();
file.setId(id);
file.setUser(user);
file.setFilename(filename);
file.setPath(path);
file.setContentType(directory ? "directory" : "text/plain");
file.setSize(directory ? 0L : 5L);
file.setDirectory(directory);
file.setCreatedAt(LocalDateTime.of(2026, 4, 8, 10, 0));
file.setUpdatedAt(LocalDateTime.of(2026, 4, 8, 11, 0));
return file;
}
}

View File

@@ -0,0 +1,167 @@
package com.yoyuzh.files;
import com.fasterxml.jackson.databind.ObjectMapper;
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 javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class MediaMetadataBackgroundTaskHandlerTest {
@Mock
private StoredFileRepository storedFileRepository;
@Mock
private FileMetadataRepository fileMetadataRepository;
@Mock
private FileContentStorage fileContentStorage;
private MediaMetadataBackgroundTaskHandler handler;
@BeforeEach
void setUp() {
handler = new MediaMetadataBackgroundTaskHandler(
storedFileRepository,
fileMetadataRepository,
fileContentStorage,
new ObjectMapper()
);
}
@Test
void shouldExtractImageMetadataFromPngBlob() throws Exception {
BackgroundTask task = createTask(11L);
StoredFile file = createFile(11L, false, "image/png", 64L, "blobs/photo.png");
byte[] pngBytes = createPngBytes(2, 1);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(11L, 7L)).thenReturn(Optional.of(file));
when(fileContentStorage.readBlob("blobs/photo.png")).thenReturn(pngBytes);
when(fileMetadataRepository.findByFileIdAndName(11L, "media:contentType")).thenReturn(Optional.empty());
when(fileMetadataRepository.findByFileIdAndName(11L, "media:size")).thenReturn(Optional.empty());
when(fileMetadataRepository.findByFileIdAndName(11L, "media:width")).thenReturn(Optional.empty());
when(fileMetadataRepository.findByFileIdAndName(11L, "media:height")).thenReturn(Optional.empty());
when(fileMetadataRepository.save(any(FileMetadata.class))).thenAnswer(invocation -> invocation.getArgument(0));
BackgroundTaskHandlerResult result = handler.handle(task);
assertThat(result.publicStatePatch()).containsEntry("worker", "media-metadata");
assertThat(result.publicStatePatch()).containsEntry("metadataExtracted", true);
assertThat(result.publicStatePatch()).containsEntry("mediaContentType", "image/png");
assertThat(result.publicStatePatch()).containsEntry("mediaSize", 64L);
assertThat(result.publicStatePatch()).containsEntry("mediaWidth", 2);
assertThat(result.publicStatePatch()).containsEntry("mediaHeight", 1);
verify(fileContentStorage).readBlob("blobs/photo.png");
ArgumentCaptor<FileMetadata> captor = ArgumentCaptor.forClass(FileMetadata.class);
verify(fileMetadataRepository, times(4)).save(captor.capture());
List<FileMetadata> saved = captor.getAllValues();
assertThat(saved).extracting(FileMetadata::getName)
.containsExactly("media:contentType", "media:size", "media:width", "media:height");
assertThat(saved).extracting(FileMetadata::getValue)
.containsExactly("image/png", "64", "2", "1");
}
@Test
void shouldWriteBaseMetadataForVideoBlobWithoutDimensions() {
BackgroundTask task = createTask(12L);
StoredFile file = createFile(12L, false, "video/mp4", 128L, "blobs/movie.mp4");
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(12L, 7L)).thenReturn(Optional.of(file));
when(fileContentStorage.readBlob("blobs/movie.mp4")).thenReturn(new byte[] {0, 1, 2});
when(fileMetadataRepository.findByFileIdAndName(12L, "media:contentType")).thenReturn(Optional.empty());
when(fileMetadataRepository.findByFileIdAndName(12L, "media:size")).thenReturn(Optional.empty());
when(fileMetadataRepository.save(any(FileMetadata.class))).thenAnswer(invocation -> invocation.getArgument(0));
BackgroundTaskHandlerResult result = handler.handle(task);
assertThat(result.publicStatePatch()).containsEntry("worker", "media-metadata");
assertThat(result.publicStatePatch()).containsEntry("metadataExtracted", true);
assertThat(result.publicStatePatch()).containsEntry("mediaContentType", "video/mp4");
assertThat(result.publicStatePatch()).containsEntry("mediaSize", 128L);
assertThat(result.publicStatePatch()).doesNotContainKeys("mediaWidth", "mediaHeight");
verify(fileMetadataRepository, times(2)).save(any(FileMetadata.class));
}
@Test
void shouldRejectMissingFileDirectoryOrBlob() {
BackgroundTask task = createTask(13L);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(13L, 7L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> handler.handle(task))
.isInstanceOf(IllegalStateException.class)
.hasMessage("media metadata task file not found");
StoredFile directory = createFile(13L, true, null, 0L, null);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(13L, 7L)).thenReturn(Optional.of(directory));
assertThatThrownBy(() -> handler.handle(task))
.isInstanceOf(IllegalStateException.class)
.hasMessage("media metadata task only supports files");
StoredFile missingBlob = createFile(13L, false, "image/png", 10L, null);
when(storedFileRepository.findByIdAndUserIdAndDeletedAtIsNull(13L, 7L)).thenReturn(Optional.of(missingBlob));
assertThatThrownBy(() -> handler.handle(task))
.isInstanceOf(IllegalStateException.class)
.hasMessage("media metadata task requires blob");
}
@Test
void shouldKeepNoopHandlerLimitedToArchiveAndExtract() {
NoopBackgroundTaskHandler noop = new NoopBackgroundTaskHandler();
assertThat(noop.supports(BackgroundTaskType.ARCHIVE)).isTrue();
assertThat(noop.supports(BackgroundTaskType.EXTRACT)).isTrue();
assertThat(noop.supports(BackgroundTaskType.MEDIA_META)).isFalse();
}
private BackgroundTask createTask(Long fileId) {
BackgroundTask task = new BackgroundTask();
task.setId(99L);
task.setType(BackgroundTaskType.MEDIA_META);
task.setStatus(BackgroundTaskStatus.RUNNING);
task.setUserId(7L);
task.setPublicStateJson("{\"fileId\":" + fileId + "}");
task.setPrivateStateJson("{\"fileId\":" + fileId + ",\"taskType\":\"MEDIA_META\"}");
return task;
}
private StoredFile createFile(Long id, boolean directory, String contentType, Long size, String objectKey) {
StoredFile file = new StoredFile();
file.setId(id);
file.setDirectory(directory);
file.setContentType(contentType);
file.setSize(size);
if (objectKey != null) {
FileBlob blob = new FileBlob();
blob.setId(100L);
blob.setObjectKey(objectKey);
blob.setContentType(contentType);
blob.setSize(size);
file.setBlob(blob);
}
return file;
}
private byte[] createPngBytes(int width, int height) throws Exception {
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
image.setRGB(0, 0, Color.RED.getRGB());
ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(image, "png", out);
return out.toByteArray();
}
}