feat(backend): add ThumbnailAsyncRunner with afterCommit dispatch and timeout

Bridges @Transactional upload paths to the async thumbnail pipeline.
dispatchAfterCommit registers a TransactionSynchronization so the async
task only fires after the surrounding commit (and is silently skipped
on rollback) — mirrors the AuditService.logAfterCommit pattern.

generateAsync wraps the full ThumbnailService.generate call in a 30s
watchdog so a hung PDFBox render cannot occupy a thumbnailExecutor slot
indefinitely.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 21:49:26 +02:00
parent 955c497ba0
commit 3b7ef6117e
2 changed files with 210 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
package org.raddatz.familienarchiv.service;
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;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class ThumbnailAsyncRunnerTest {
private DocumentRepository documentRepository;
private ThumbnailService thumbnailService;
private ThumbnailAsyncRunner runner;
@BeforeEach
void setUp() {
documentRepository = mock(DocumentRepository.class);
thumbnailService = mock(ThumbnailService.class);
runner = new ThumbnailAsyncRunner(documentRepository, 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));
runner.dispatchAfterCommit(id);
verify(thumbnailService).generate(doc);
}
@Test
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));
TransactionSynchronizationManager.initSynchronization();
try {
runner.dispatchAfterCommit(id);
// Nothing fired yet — registered, not executed
verify(thumbnailService, never()).generate(any());
// Simulate commit
ArgumentCaptor<TransactionSynchronization> captor =
ArgumentCaptor.forClass(TransactionSynchronization.class);
assertThat(TransactionSynchronizationManager.getSynchronizations()).hasSize(1);
TransactionSynchronizationManager.getSynchronizations().get(0).afterCommit();
verify(thumbnailService).generate(doc);
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
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));
TransactionSynchronizationManager.initSynchronization();
try {
runner.dispatchAfterCommit(id);
// Simulate rollback — afterCompletion with STATUS_ROLLED_BACK, no afterCommit fired
TransactionSynchronizationManager.getSynchronizations().get(0)
.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
verify(thumbnailService, never()).generate(any());
} finally {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void generateAsync_skipsWhenDocumentMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
runner.generateAsync(id);
verifyNoInteractions(thumbnailService);
}
@Test
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));
// generate sleeps longer than the timeout — simulates a hung PDFBox render
when(thumbnailService.generate(doc)).thenAnswer(inv -> {
Thread.sleep(5_000);
return ThumbnailService.Outcome.SUCCESS;
});
// Shrink timeout for the test
ReflectionTestUtils.setField(runner, "generateTimeoutSeconds", 1L);
long start = System.currentTimeMillis();
runner.generateAsync(id);
long elapsed = System.currentTimeMillis() - start;
// Must return before the 5s sleep — within ~2s with timeout=1s plus overhead
assertThat(elapsed).isLessThan(3_000);
}
}