feat(bulk-edit): add PATCH /api/documents/bulk endpoint
WRITE_ALL-gated batch endpoint that applies a partial DTO to up to 500 documents per request. Per-document failures (DOCUMENT_NOT_FOUND, etc.) are collected into the response's errors[] without aborting the batch. Logs an audit line consistent with quickUpload. Refs #225 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,10 @@ import jakarta.validation.constraints.Min;
|
|||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.validation.annotation.Validated;
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.raddatz.familienarchiv.dto.BulkEditError;
|
||||||
|
import org.raddatz.familienarchiv.dto.BulkEditResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
import org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.DocumentBulkEditDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||||
@@ -237,6 +240,45 @@ public class DocumentController {
|
|||||||
return new QuickUploadResult(created, updated, errors);
|
return new QuickUploadResult(created, updated, errors);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- BULK EDIT ---
|
||||||
|
|
||||||
|
private static final int BULK_EDIT_MAX_IDS = 500;
|
||||||
|
|
||||||
|
@PatchMapping("/bulk")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public BulkEditResult patchBulk(
|
||||||
|
@RequestBody DocumentBulkEditDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
if (dto.getDocumentIds() == null || dto.getDocumentIds().isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "documentIds is required");
|
||||||
|
}
|
||||||
|
if (dto.getDocumentIds().size() > BULK_EDIT_MAX_IDS) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.BULK_EDIT_TOO_MANY_IDS,
|
||||||
|
"Maximum " + BULK_EDIT_MAX_IDS + " documents per request, got: " + dto.getDocumentIds().size());
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID actorId = requireUserId(authentication);
|
||||||
|
int updated = 0;
|
||||||
|
List<BulkEditError> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
for (UUID id : dto.getDocumentIds()) {
|
||||||
|
try {
|
||||||
|
documentService.applyBulkEditToDocument(id, dto);
|
||||||
|
updated++;
|
||||||
|
} catch (DomainException e) {
|
||||||
|
errors.add(new BulkEditError(id, e.getMessage()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
errors.add(new BulkEditError(id, "Internal error"));
|
||||||
|
log.warn("Bulk edit failed for document {}: {}", id, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("bulkEdit actor={} documentIds={} updated={} errors={}",
|
||||||
|
actorId, dto.getDocumentIds().size(), updated, errors.size());
|
||||||
|
|
||||||
|
return new BulkEditResult(updated, errors);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/incomplete-count")
|
@GetMapping("/incomplete-count")
|
||||||
@RequirePermission(Permission.WRITE_ALL)
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public Map<String, Long> getIncompleteCount() {
|
public Map<String, Long> getIncompleteCount() {
|
||||||
|
|||||||
@@ -929,4 +929,112 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isBadRequest())
|
.andExpect(status().isBadRequest())
|
||||||
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
.andExpect(jsonPath("$.code").value("BATCH_TOO_LARGE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/documents/bulk ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String bulkBody(String... uuids) {
|
||||||
|
StringBuilder sb = new StringBuilder("{\"documentIds\":[");
|
||||||
|
for (int i = 0; i < uuids.length; i++) {
|
||||||
|
if (i > 0) sb.append(",");
|
||||||
|
sb.append("\"").append(uuids[i]).append("\"");
|
||||||
|
}
|
||||||
|
sb.append("]}");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void patchBulk_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void patchBulk_returns403_forReadAllUser() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(UUID.randomUUID().toString())))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns400_whenDocumentIdsIsEmpty() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"documentIds\":[]}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns400_whenDocumentIdsIsMissing() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns400_whenDocumentIdsExceedsCap() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
|
||||||
|
String[] ids = new String[501];
|
||||||
|
for (int i = 0; i < 501; i++) ids[i] = UUID.randomUUID().toString();
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(ids)))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value("BULK_EDIT_TOO_MANY_IDS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returns200_andCallsServiceForEachId() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
when(documentService.applyBulkEditToDocument(any(), any()))
|
||||||
|
.thenAnswer(inv -> Document.builder().id(inv.getArgument(0)).build());
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(id1.toString(), id2.toString())))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.updated").value(2))
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
|
||||||
|
verify(documentService).applyBulkEditToDocument(eq(id1), any());
|
||||||
|
verify(documentService).applyBulkEditToDocument(eq(id2), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void patchBulk_returnsPartialFailureShape_whenServiceThrowsForOneDocument() throws Exception {
|
||||||
|
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
|
||||||
|
UUID okId = UUID.randomUUID();
|
||||||
|
UUID badId = UUID.randomUUID();
|
||||||
|
when(documentService.applyBulkEditToDocument(eq(okId), any()))
|
||||||
|
.thenAnswer(inv -> Document.builder().id(okId).build());
|
||||||
|
when(documentService.applyBulkEditToDocument(eq(badId), any()))
|
||||||
|
.thenThrow(org.raddatz.familienarchiv.exception.DomainException.notFound(
|
||||||
|
org.raddatz.familienarchiv.exception.ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + badId));
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/bulk")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bulkBody(okId.toString(), badId.toString())))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.updated").value(1))
|
||||||
|
.andExpect(jsonPath("$.errors[0].id").value(badId.toString()))
|
||||||
|
.andExpect(jsonPath("$.errors[0].message").value(
|
||||||
|
org.hamcrest.Matchers.containsString("not found")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user