feat(upload): add POST /api/documents/quick-upload endpoint for bulk file upload
Adds a new multipart endpoint that accepts multiple files and creates one document per file without requiring any form metadata. Each document gets title = filename-without-extension and status = UPLOADED. - Fix storeDocument() to strip the file extension from the document title - Validate content type (PDF/JPEG/PNG/TIFF) server-side; unsupported files are skipped and returned as per-file errors in QuickUploadResult - Tests cover 401/403 auth, success path, and unsupported file type Closes #66 (backend part) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
@@ -103,6 +105,40 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- QUICK UPLOAD ---
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
|
||||||
|
"application/pdf", "image/jpeg", "image/png", "image/tiff");
|
||||||
|
|
||||||
|
public record QuickUploadResult(List<Document> created, List<String> errors) {}
|
||||||
|
|
||||||
|
@PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public QuickUploadResult quickUpload(
|
||||||
|
@RequestPart(value = "files", required = false) List<MultipartFile> files) {
|
||||||
|
List<Document> created = new ArrayList<>();
|
||||||
|
List<String> errors = new ArrayList<>();
|
||||||
|
|
||||||
|
if (files == null || files.isEmpty()) {
|
||||||
|
return new QuickUploadResult(created, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (MultipartFile file : files) {
|
||||||
|
if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) {
|
||||||
|
errors.add(file.getOriginalFilename() + ": unsupported file type");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
created.add(documentService.storeDocument(file));
|
||||||
|
} catch (Exception e) {
|
||||||
|
errors.add(file.getOriginalFilename() + ": " + e.getMessage());
|
||||||
|
log.warn("Quick upload failed for file {}: {}", file.getOriginalFilename(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QuickUploadResult(created, errors);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<List<Document>> search(
|
public ResponseEntity<List<Document>> search(
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public class DocumentService {
|
|||||||
} else {
|
} else {
|
||||||
document = Document.builder()
|
document = Document.builder()
|
||||||
.originalFilename(originalFilename)
|
.originalFilename(originalFilename)
|
||||||
.title(originalFilename)
|
.title(stripExtension(originalFilename))
|
||||||
.status(DocumentStatus.UPLOADED)
|
.status(DocumentStatus.UPLOADED)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
@@ -307,6 +307,12 @@ public class DocumentService {
|
|||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String stripExtension(String filename) {
|
||||||
|
if (filename == null) return null;
|
||||||
|
int dot = filename.lastIndexOf('.');
|
||||||
|
return dot > 0 ? filename.substring(0, dot) : filename;
|
||||||
|
}
|
||||||
|
|
||||||
private static String sha256Hex(byte[] bytes) {
|
private static String sha256Hex(byte[] bytes) {
|
||||||
try {
|
try {
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import java.util.Collections;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.containsString;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
@@ -121,6 +122,50 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/quick-upload ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void quickUpload_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void quickUpload_returns403_whenMissingWritePermission() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_returns200_withValidPdfFile() throws Exception {
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||||
|
when(documentService.storeDocument(any())).thenReturn(doc);
|
||||||
|
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created[0].title").value("scan001"))
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_skipsUnsupportedFileType_andReturnsError() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("files", "report.docx",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", new byte[]{1});
|
||||||
|
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload").file(file))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors[0]").value(containsString("report.docx")));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -212,6 +212,44 @@ class DocumentServiceTest {
|
|||||||
verify(documentVersionService).recordVersion(any(Document.class));
|
verify(documentVersionService).recordVersion(any(Document.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── storeDocument ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_setsTitle_withoutFileExtension_forNewDocument() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("scan001").originalFilename("scan001.pdf").build();
|
||||||
|
|
||||||
|
when(documentRepository.findByOriginalFilename("scan001.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
verify(documentRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getTitle()).isEqualTo("scan001");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeDocument_preservesExistingTitle_whenPlaceholderAlreadyExists() throws Exception {
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "scan001.pdf", "application/pdf", new byte[]{1});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan001.pdf", "abc123");
|
||||||
|
Document placeholder = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("Brief an Oma").originalFilename("scan001.pdf")
|
||||||
|
.status(org.raddatz.familienarchiv.model.DocumentStatus.PLACEHOLDER).build();
|
||||||
|
|
||||||
|
when(documentRepository.findByOriginalFilename("scan001.pdf")).thenReturn(Optional.of(placeholder));
|
||||||
|
when(documentRepository.save(any())).thenReturn(placeholder);
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
|
||||||
|
documentService.storeDocument(file);
|
||||||
|
|
||||||
|
assertThat(placeholder.getTitle()).isEqualTo("Brief an Oma");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user