diff --git a/backend/lombok.config b/backend/lombok.config new file mode 100644 index 00000000..a0191809 --- /dev/null +++ b/backend/lombok.config @@ -0,0 +1 @@ +lombok.copyableAnnotations += org.springframework.context.annotation.Lazy 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 6cf4cb61..399395b4 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/DocumentService.java @@ -25,6 +25,7 @@ import org.raddatz.familienarchiv.model.TrainingLabel; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.repository.DocumentRepository; +import org.springframework.context.annotation.Lazy; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -69,6 +70,10 @@ public class DocumentService { private final AuditService auditService; private final TranscriptionBlockQueryService transcriptionBlockQueryService; private final AuditLogQueryService auditLogQueryService; + // @Lazy breaks the DocumentService ↔ ThumbnailAsyncRunner cycle: the runner + // now reaches Document data through DocumentService (per the layering rule), + // and Spring needs a proxy here to defer the back-edge until both beans exist. + @Lazy private final ThumbnailAsyncRunner thumbnailAsyncRunner; public record StoreResult(Document document, boolean isNew) {} @@ -77,6 +82,18 @@ public class DocumentService { return documentRepository.count(); } + public Optional findById(UUID id) { + return documentRepository.findById(id); + } + + public List findForThumbnailBackfill() { + return documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull(); + } + + public Document updateThumbnailMetadata(Document doc) { + return documentRepository.save(doc); + } + public Map findTitlesByIds(Collection ids) { if (ids.isEmpty()) return Map.of(); Map titles = new HashMap<>(); diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java index a90a7a39..1b5cd867 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunner.java @@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.service; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.model.Document; -import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.support.TransactionSynchronization; @@ -29,7 +28,7 @@ import java.util.concurrent.TimeoutException; @Slf4j public class ThumbnailAsyncRunner { - private final DocumentRepository documentRepository; + private final DocumentService documentService; private final ThumbnailService thumbnailService; /** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */ @@ -60,7 +59,7 @@ public class ThumbnailAsyncRunner { */ @Async("thumbnailExecutor") public void generateAsync(UUID documentId) { - Optional docOpt = documentRepository.findById(documentId); + Optional docOpt = documentService.findById(documentId); if (docOpt.isEmpty()) { log.warn("Thumbnail generation skipped: document not found id={}", documentId); return; diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java index 60855ba6..9c230d1c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailBackfillService.java @@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; -import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -37,7 +36,7 @@ public class ThumbnailBackfillService { LocalDateTime startedAt ) {} - private final DocumentRepository documentRepository; + private final DocumentService documentService; private final ThumbnailService thumbnailService; private volatile BackfillStatus currentStatus = new BackfillStatus( @@ -57,7 +56,7 @@ public class ThumbnailBackfillService { LocalDateTime startedAt = LocalDateTime.now(); List docs; try { - docs = documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull(); + docs = documentService.findForThumbnailBackfill(); } catch (Exception e) { currentStatus = new BackfillStatus(State.FAILED, "Backfill fehlgeschlagen: " + e.getMessage(), diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java index f654b922..b1441437 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/ThumbnailService.java @@ -8,7 +8,6 @@ import org.apache.pdfbox.rendering.ImageType; import org.apache.pdfbox.rendering.PDFRenderer; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.ThumbnailAspect; -import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import software.amazon.awssdk.core.sync.RequestBody; @@ -62,16 +61,16 @@ public class ThumbnailService { private final FileService fileService; private final S3Client s3Client; - private final DocumentRepository documentRepository; + private final DocumentService documentService; @Value("${app.s3.bucket}") private String bucketName; public ThumbnailService(FileService fileService, S3Client s3Client, - DocumentRepository documentRepository) { + DocumentService documentService) { this.fileService = fileService; this.s3Client = s3Client; - this.documentRepository = documentRepository; + this.documentService = documentService; } public Outcome generate(Document doc) { @@ -167,7 +166,7 @@ public class ThumbnailService { doc.setThumbnailGeneratedAt(LocalDateTime.now()); doc.setThumbnailAspect(result.aspect()); doc.setPageCount(result.pageCount()); - documentRepository.save(doc); + documentService.updateThumbnailMetadata(doc); return Outcome.SUCCESS; } catch (Exception e) { // Thumbnail is already in S3 but the entity update failed. Because the S3 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 f3a95e4f..7136d245 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/DocumentServiceTest.java @@ -2264,4 +2264,46 @@ class DocumentServiceTest { assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder"); assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation"); } + + // ─── findById (no-throw variant) ─────────────────────────────────────────── + + @Test + void findById_returnsEmpty_whenDocumentDoesNotExist() { + UUID id = UUID.randomUUID(); + when(documentRepository.findById(id)).thenReturn(Optional.empty()); + + assertThat(documentService.findById(id)).isEmpty(); + } + + @Test + void findById_returnsDocument_whenPresent() { + UUID id = UUID.randomUUID(); + Document doc = Document.builder().id(id).title("T").build(); + when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + + assertThat(documentService.findById(id)).contains(doc); + } + + // ─── findForThumbnailBackfill ────────────────────────────────────────────── + + @Test + void findForThumbnailBackfill_returnsRepositoryResult() { + Document a = Document.builder().id(UUID.randomUUID()).title("A").build(); + Document b = Document.builder().id(UUID.randomUUID()).title("B").build(); + when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + .thenReturn(List.of(a, b)); + + assertThat(documentService.findForThumbnailBackfill()).containsExactly(a, b); + } + + // ─── updateThumbnailMetadata ─────────────────────────────────────────────── + + @Test + void updateThumbnailMetadata_savesDocument() { + Document doc = Document.builder().id(UUID.randomUUID()).title("T").build(); + when(documentRepository.save(doc)).thenReturn(doc); + + assertThat(documentService.updateThumbnailMetadata(doc)).isEqualTo(doc); + verify(documentRepository).save(doc); + } } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java index 2f55b889..71909848 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailAsyncRunnerTest.java @@ -4,7 +4,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.raddatz.familienarchiv.model.Document; -import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -18,22 +17,22 @@ import static org.mockito.Mockito.*; class ThumbnailAsyncRunnerTest { - private DocumentRepository documentRepository; + private DocumentService documentService; private ThumbnailService thumbnailService; private ThumbnailAsyncRunner runner; @BeforeEach void setUp() { - documentRepository = mock(DocumentRepository.class); + documentService = mock(DocumentService.class); thumbnailService = mock(ThumbnailService.class); - runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService); + runner = new ThumbnailAsyncRunner(documentService, thumbnailService); } @Test void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() { UUID id = UUID.randomUUID(); Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); - when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentService.findById(id)).thenReturn(Optional.of(doc)); runner.dispatchAfterCommit(id); @@ -44,7 +43,7 @@ class ThumbnailAsyncRunnerTest { void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() { UUID id = UUID.randomUUID(); Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); - when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentService.findById(id)).thenReturn(Optional.of(doc)); TransactionSynchronizationManager.initSynchronization(); try { @@ -69,7 +68,7 @@ class ThumbnailAsyncRunnerTest { void dispatchAfterCommit_whenRollback_doesNotDispatch() { UUID id = UUID.randomUUID(); Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); - when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentService.findById(id)).thenReturn(Optional.of(doc)); TransactionSynchronizationManager.initSynchronization(); try { @@ -88,7 +87,7 @@ class ThumbnailAsyncRunnerTest { @Test void generateAsync_skipsWhenDocumentMissing() { UUID id = UUID.randomUUID(); - when(documentRepository.findById(id)).thenReturn(Optional.empty()); + when(documentService.findById(id)).thenReturn(Optional.empty()); runner.generateAsync(id); @@ -99,7 +98,7 @@ class ThumbnailAsyncRunnerTest { void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception { UUID id = UUID.randomUUID(); Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); - when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); + when(documentService.findById(id)).thenReturn(Optional.of(doc)); // generate sleeps longer than the timeout — simulates a hung PDFBox render when(thumbnailService.generate(doc)).thenAnswer(inv -> { Thread.sleep(5_000); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java index 4e076395..319e7af4 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailBackfillServiceTest.java @@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test; import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.model.Document; -import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; @@ -19,15 +18,15 @@ import static org.mockito.Mockito.*; class ThumbnailBackfillServiceTest { - private DocumentRepository documentRepository; + private DocumentService documentService; private ThumbnailService thumbnailService; private ThumbnailBackfillService backfillService; @BeforeEach void setUp() { - documentRepository = mock(DocumentRepository.class); + documentService = mock(DocumentService.class); thumbnailService = mock(ThumbnailService.class); - backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService); + backfillService = new ThumbnailBackfillService(documentService, thumbnailService); } @Test @@ -45,7 +44,7 @@ class ThumbnailBackfillServiceTest { Document a = doc(); Document b = doc(); Document c = doc(); - when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + when(documentService.findForThumbnailBackfill()) .thenReturn(List.of(a, b, c)); when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS); @@ -64,7 +63,7 @@ class ThumbnailBackfillServiceTest { void runBackfillAsync_countsSkippedSeparately() { Document a = doc(); Document b = doc(); - when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + when(documentService.findForThumbnailBackfill()) .thenReturn(List.of(a, b)); when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS); when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED); @@ -83,7 +82,7 @@ class ThumbnailBackfillServiceTest { Document a = doc(); Document b = doc(); Document c = doc(); - when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + when(documentService.findForThumbnailBackfill()) .thenReturn(List.of(a, b, c)); when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS); when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED); @@ -102,7 +101,7 @@ class ThumbnailBackfillServiceTest { void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() { Document a = doc(); Document b = doc(); - when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + when(documentService.findForThumbnailBackfill()) .thenReturn(List.of(a, b)); when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom")); when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS); @@ -130,7 +129,7 @@ class ThumbnailBackfillServiceTest { @Test void runBackfillAsync_setsStartedAtAndMessage() { - when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) + when(documentService.findForThumbnailBackfill()) .thenReturn(List.of(doc())); when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java index ad423401..43394eab 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/ThumbnailServiceTest.java @@ -12,7 +12,6 @@ import org.mockito.ArgumentCaptor; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.ThumbnailAspect; -import org.raddatz.familienarchiv.repository.DocumentRepository; import org.springframework.test.util.ReflectionTestUtils; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; @@ -39,17 +38,17 @@ class ThumbnailServiceTest { private FileService fileService; private S3Client s3Client; - private DocumentRepository documentRepository; + private DocumentService documentService; private ThumbnailService thumbnailService; @BeforeEach void setUp() { fileService = mock(FileService.class); s3Client = mock(S3Client.class); - documentRepository = mock(DocumentRepository.class); - thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository); + documentService = mock(DocumentService.class); + thumbnailService = new ThumbnailService(fileService, s3Client, documentService); ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket"); - when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0)); + when(documentService.updateThumbnailMetadata(any(Document.class))).thenAnswer(i -> i.getArgument(0)); } @Test @@ -103,7 +102,7 @@ class ThumbnailServiceTest { assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg"); assertThat(doc.getThumbnailGeneratedAt()).isNotNull(); - verify(documentRepository).save(doc); + verify(documentService).updateThumbnailMetadata(doc); } @Test @@ -152,7 +151,7 @@ class ThumbnailServiceTest { assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); assertThat(doc.getThumbnailKey()).isNull(); - verify(documentRepository, never()).save(any()); + verify(documentService, never()).updateThumbnailMetadata(any()); } @Test @@ -165,7 +164,7 @@ class ThumbnailServiceTest { assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); verifyNoInteractions(s3Client); - verify(documentRepository, never()).save(any()); + verify(documentService, never()).updateThumbnailMetadata(any()); } @Test @@ -260,7 +259,7 @@ class ThumbnailServiceTest { assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); verifyNoInteractions(s3Client); - verify(documentRepository, never()).save(any()); + verify(documentService, never()).updateThumbnailMetadata(any()); } @Test @@ -275,7 +274,7 @@ class ThumbnailServiceTest { assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); verifyNoInteractions(s3Client); - verify(documentRepository, never()).save(any()); + verify(documentService, never()).updateThumbnailMetadata(any()); } @Test @@ -286,14 +285,14 @@ class ThumbnailServiceTest { Document doc = makeDoc("application/pdf", "documents/letter.pdf"); when(fileService.downloadFileStream(anyString())) .thenReturn(new ByteArrayInputStream(createSamplePdf())); - when(documentRepository.save(any())) + when(documentService.updateThumbnailMetadata(any())) .thenThrow(new RuntimeException("constraint violation")); ThumbnailService.Outcome outcome = thumbnailService.generate(doc); assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); - verify(documentRepository).save(any()); + verify(documentService).updateThumbnailMetadata(any()); } // ─── helpers ──────────────────────────────────────────────────────────────