refactor(documents): move batch validation from controller into DocumentService

Validation guards (BATCH_TOO_LARGE, titles > files) are domain rules and
belong in the service where they can be unit-tested without the HTTP layer.
Controller now delegates to documentService.validateBatch().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-24 20:25:17 +02:00
parent dd6331c098
commit b2264de949
4 changed files with 43 additions and 6 deletions

View File

@@ -204,12 +204,7 @@ public class DocumentController {
return new QuickUploadResult(created, updated, errors);
}
if (files.size() > 50) {
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
}
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > files.size()) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
}
documentService.validateBatch(files.size(), metadata);
UUID actorId = requireUserId(authentication);
long totalBytes = files.stream().mapToLong(MultipartFile::getSize).sum();

View File

@@ -133,6 +133,15 @@ public class DocumentService {
return new StoreResult(saved, isNew);
}
public void validateBatch(int fileCount, DocumentBatchMetadataDTO metadata) {
if (fileCount > 50) {
throw DomainException.badRequest(ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request");
}
if (metadata != null && metadata.getTitles() != null && metadata.getTitles().size() > fileCount) {
throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count");
}
}
@Transactional
public StoreResult storeDocumentWithBatchMetadata(
MultipartFile file, DocumentBatchMetadataDTO metadata, int fileIndex, UUID actorId) throws IOException {

View File

@@ -869,6 +869,10 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_withMetadata_rejects400_whenTitlesSizeExceedsFilesSize() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.badRequest(
org.raddatz.familienarchiv.exception.ErrorCode.VALIDATION_ERROR, "titles count must not exceed files count"))
.when(documentService).validateBatch(eq(2), any());
org.springframework.mock.web.MockMultipartFile f1 =
new org.springframework.mock.web.MockMultipartFile("files", "a.pdf", "application/pdf", new byte[]{1});
@@ -886,6 +890,10 @@ class DocumentControllerTest {
@WithMockUser(authorities = "WRITE_ALL")
void quickUpload_returns400_whenBatchExceedsCap() throws Exception {
when(userService.findByEmail(any())).thenReturn(AppUser.builder().id(UUID.randomUUID()).build());
org.mockito.Mockito.doThrow(
org.raddatz.familienarchiv.exception.DomainException.badRequest(
org.raddatz.familienarchiv.exception.ErrorCode.BATCH_TOO_LARGE, "Batch exceeds maximum of 50 files per request"))
.when(documentService).validateBatch(eq(51), any());
var builder = multipart("/api/documents/quick-upload");
for (int i = 0; i < 51; i++) {

View File

@@ -1813,4 +1813,29 @@ class DocumentServiceTest {
verify(auditService).logAfterCommit(eq(AuditKind.FILE_UPLOADED), isNull(), eq(id), isNull());
}
// ─── validateBatch ───────────────────────────────────────────────────────
@Test
void validateBatch_throwsBatchTooLarge_whenFileCountExceedsCap() {
assertThatThrownBy(() -> documentService.validateBatch(51, null))
.isInstanceOf(DomainException.class)
.hasMessageContaining("50");
}
@Test
void validateBatch_doesNotThrow_whenFileCountEqualsCapExactly() {
documentService.validateBatch(50, null);
}
@Test
void validateBatch_throwsValidationError_whenTitlesSizeExceedsFileCount() {
org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO metadata =
new org.raddatz.familienarchiv.dto.DocumentBatchMetadataDTO();
metadata.setTitles(java.util.List.of("A", "B", "C"));
assertThatThrownBy(() -> documentService.validateBatch(2, metadata))
.isInstanceOf(DomainException.class)
.hasMessageContaining("titles");
}
}