diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java index f23f465b..a405577d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/DocumentController.java @@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller; import java.io.IOException; import java.time.LocalDate; +import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; @@ -103,6 +105,40 @@ public class DocumentController { } } + // --- QUICK UPLOAD --- + + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + "application/pdf", "image/jpeg", "image/png", "image/tiff"); + + public record QuickUploadResult(List created, List errors) {} + + @PostMapping(value = "/quick-upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @RequirePermission(Permission.WRITE_ALL) + public QuickUploadResult quickUpload( + @RequestPart(value = "files", required = false) List files) { + List created = new ArrayList<>(); + List 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") public ResponseEntity> search( @RequestParam(required = false) String q, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java index a79a8f22..45cfd2da 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -61,7 +61,7 @@ public class DocumentService { } else { document = Document.builder() .originalFilename(originalFilename) - .title(originalFilename) + .title(stripExtension(originalFilename)) .status(DocumentStatus.UPLOADED) .build(); } @@ -307,6 +307,12 @@ public class DocumentService { // ─── 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) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java index 545e98f3..0f64fddc 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/DocumentControllerTest.java @@ -23,6 +23,7 @@ import java.util.Collections; import java.util.List; import java.util.UUID; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -121,6 +122,50 @@ class DocumentControllerTest { .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 ──────────────────────────────────── @Test diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java index b6fc3dea..36e7c2eb 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -212,6 +212,44 @@ class DocumentServiceTest { 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 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 ─────────────────────────────────────────────────── @Test