refactor(thumbnail): route document access through DocumentService

The Thumbnail trio (ThumbnailService, ThumbnailBackfillService,
ThumbnailAsyncRunner) all injected DocumentRepository directly. They now go
through three new DocumentService delegations:

- findById(UUID): Optional<Document> — no-throw variant for the runner's
  log-and-skip behaviour on missing documents.
- findForThumbnailBackfill() — wraps the existing
  findByFilePathIsNotNullAndThumbnailKeyIsNull query.
- updateThumbnailMetadata(Document) — wraps save() for the post-thumbnail
  entity update.

DocumentService also gains @Lazy on its existing ThumbnailAsyncRunner field
to break the new DocumentService ↔ ThumbnailAsyncRunner cycle. lombok.config
adds @Lazy to copyableAnnotations so the field annotation reaches the
generated constructor parameter.

Refs #417 (C6.2 violations #2, #3, #4).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 07:20:01 +02:00
parent 5c1332cb0e
commit e2e7b79067
9 changed files with 95 additions and 41 deletions

View File

@@ -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);
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 ──────────────────────────────────────────────────────────────