From 7d0e13c591c12745842644b11580292f975ff12f Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 22 Apr 2026 21:57:36 +0200 Subject: [PATCH] feat(backend): dispatch thumbnail generation from DocumentService upload paths All four upload code paths (storeDocument, createDocument, updateDocument, attachFile) now call thumbnailAsyncRunner.dispatchAfterCommit(id) after the document save. createDocument and updateDocument only dispatch when a file was actually provided/replaced. The dispatch is afterCommit-safe: if the surrounding @Transactional method rolls back, no thumbnail is generated for a document that never reached the DB. Refs #307 Co-Authored-By: Claude Opus 4.7 --- .../service/DocumentService.java | 16 ++- .../service/DocumentServiceTest.java | 102 ++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) 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 fc26dcc3..5b563e47 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -64,6 +64,7 @@ public class DocumentService { private final AuditService auditService; private final TranscriptionBlockQueryService transcriptionBlockQueryService; private final AuditLogQueryService auditLogQueryService; + private final ThumbnailAsyncRunner thumbnailAsyncRunner; public record StoreResult(Document document, boolean isNew) {} @@ -125,6 +126,7 @@ public class DocumentService { if (wasPlaceholder) { auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null); } + thumbnailAsyncRunner.dispatchAfterCommit(saved.getId()); return new StoreResult(saved, isNew); } @@ -187,7 +189,8 @@ public class DocumentService { } // Datei - if (file != null && !file.isEmpty()) { + boolean fileUploaded = file != null && !file.isEmpty(); + if (fileUploaded) { FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename()); doc.setFilePath(upload.s3Key()); doc.setFileHash(upload.fileHash()); @@ -197,6 +200,9 @@ public class DocumentService { Document finalDoc = documentRepository.save(doc); documentVersionService.recordVersion(finalDoc); + if (fileUploaded) { + thumbnailAsyncRunner.dispatchAfterCommit(finalDoc.getId()); + } return finalDoc; } @@ -249,7 +255,8 @@ public class DocumentService { } // 4. Datei austauschen (nur wenn eine neue ausgewählt wurde) - if (newFile != null && !newFile.isEmpty()) { + boolean fileReplaced = newFile != null && !newFile.isEmpty(); + if (fileReplaced) { FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename()); doc.setFilePath(upload.s3Key()); doc.setFileHash(upload.fileHash()); @@ -268,6 +275,10 @@ public class DocumentService { auditService.logAfterCommit(AuditKind.METADATA_UPDATED, actorId, saved.getId(), null); } + if (fileReplaced) { + thumbnailAsyncRunner.dispatchAfterCommit(saved.getId()); + } + return saved; } @@ -329,6 +340,7 @@ public class DocumentService { } Document saved = documentRepository.save(doc); documentVersionService.recordVersion(saved); + thumbnailAsyncRunner.dispatchAfterCommit(saved.getId()); if (wasPlaceholder) { auditService.logAfterCommit(AuditKind.FILE_UPLOADED, actorId, saved.getId(), null); } 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 7eefde78..657c822d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -55,6 +55,7 @@ class DocumentServiceTest { @Mock AuditService auditService; @Mock AuditLogQueryService auditLogQueryService; @Mock TranscriptionBlockQueryService transcriptionBlockQueryService; + @Mock ThumbnailAsyncRunner thumbnailAsyncRunner; @InjectMocks DocumentService documentService; // ─── deleteDocument ─────────────────────────────────────────────────────── @@ -257,6 +258,107 @@ class DocumentServiceTest { verify(documentVersionService).recordVersion(any(Document.class)); } + // ─── thumbnail dispatch ─────────────────────────────────────────────────── + + @Test + void storeDocument_dispatchesThumbnailAfterSave() throws Exception { + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1}); + UUID savedId = UUID.randomUUID(); + Document saved = Document.builder().id(savedId).originalFilename("new.pdf").build(); + when(documentRepository.findFirstByOriginalFilename("new.pdf")).thenReturn(Optional.empty()); + when(documentRepository.save(any())).thenReturn(saved); + when(fileService.uploadFile(any(), any())).thenReturn(new FileService.UploadResult("documents/new.pdf", "hash")); + + documentService.storeDocument(file, null); + + verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId); + } + + @Test + void createDocument_dispatchesThumbnail_onlyWhenFileProvided() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("No file"); + UUID savedId = UUID.randomUUID(); + Document saved = Document.builder().id(savedId).title("No file") + .originalFilename("No file").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + + documentService.createDocument(dto, null); + + verifyNoInteractions(thumbnailAsyncRunner); + } + + @Test + void createDocument_dispatchesThumbnail_whenFileProvided() throws Exception { + DocumentUpdateDTO dto = new DocumentUpdateDTO(); + dto.setTitle("With file"); + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1}); + UUID savedId = UUID.randomUUID(); + Document saved = Document.builder().id(savedId).title("With file") + .originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build(); + when(documentRepository.save(any())).thenReturn(saved); + when(documentRepository.findById(any())).thenReturn(Optional.of(saved)); + when(fileService.uploadFile(any(), any())) + .thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash")); + + documentService.createDocument(dto, file); + + verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(savedId); + } + + @Test + void updateDocument_dispatchesThumbnail_onlyWhenFileReplaced() throws Exception { + UUID id = UUID.randomUUID(); + Document existing = Document.builder() + .id(id).title("Doc").originalFilename("old.pdf") + .status(DocumentStatus.UPLOADED).build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(existing)); + when(documentRepository.save(any())).thenReturn(existing); + + documentService.updateDocument(id, new DocumentUpdateDTO(), null, null); + + verifyNoInteractions(thumbnailAsyncRunner); + } + + @Test + void updateDocument_dispatchesThumbnail_whenNewFileProvided() throws Exception { + UUID id = UUID.randomUUID(); + Document existing = Document.builder() + .id(id).title("Doc").originalFilename("old.pdf") + .status(DocumentStatus.UPLOADED).build(); + org.springframework.mock.web.MockMultipartFile newFile = + new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{1}); + when(documentRepository.findById(id)).thenReturn(Optional.of(existing)); + when(documentRepository.save(any())).thenReturn(existing); + when(fileService.uploadFile(any(), any())) + .thenReturn(new FileService.UploadResult("documents/new.pdf", "hash")); + + documentService.updateDocument(id, new DocumentUpdateDTO(), newFile, null); + + verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id); + } + + @Test + void attachFile_dispatchesThumbnailAfterSave() throws Exception { + UUID id = UUID.randomUUID(); + Document existing = Document.builder() + .id(id).title("Placeholder").originalFilename("placeholder") + .status(DocumentStatus.PLACEHOLDER).build(); + org.springframework.mock.web.MockMultipartFile file = + new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1}); + when(documentRepository.findById(id)).thenReturn(Optional.of(existing)); + when(documentRepository.save(any())).thenReturn(existing); + when(fileService.uploadFile(any(), any())) + .thenReturn(new FileService.UploadResult("documents/scan.pdf", "hash")); + + documentService.attachFile(id, file, null); + + verify(thumbnailAsyncRunner, times(1)).dispatchAfterCommit(id); + } + // ─── storeDocument ─────────────────────────────────────────────────────── @Test