feat(backend): add ThumbnailBackfillService for regenerating missing thumbnails

Sequentially processes all documents with a file but no thumbnail and
tallies processed / skipped / failed counts. Runs on thumbnailExecutor
so it shares back-pressure with live upload thumbnails but can never
saturate them (single-threaded loop).

Concurrent start rejected with THUMBNAIL_BACKFILL_ALREADY_RUNNING.
Emits a structured summary log line on completion for operator
visibility.

Refs #307

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-22 22:02:20 +02:00
parent 0344a0c7ff
commit 09fc871756
2 changed files with 258 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.BeforeEach;
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;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class ThumbnailBackfillServiceTest {
private DocumentRepository documentRepository;
private ThumbnailService thumbnailService;
private ThumbnailBackfillService backfillService;
@BeforeEach
void setUp() {
documentRepository = mock(DocumentRepository.class);
thumbnailService = mock(ThumbnailService.class);
backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService);
}
@Test
void initialStatus_isIdle() {
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.IDLE);
assertThat(status.total()).isZero();
assertThat(status.processed()).isZero();
assertThat(status.startedAt()).isNull();
}
@Test
void runBackfillAsync_processesAllDocumentsAndFinishesDone() {
Document a = doc();
Document b = doc();
Document c = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b, c));
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
assertThat(status.total()).isEqualTo(3);
assertThat(status.processed()).isEqualTo(3);
assertThat(status.skipped()).isZero();
assertThat(status.failed()).isZero();
verify(thumbnailService, times(3)).generate(any());
}
@Test
void runBackfillAsync_countsSkippedSeparately() {
Document a = doc();
Document b = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b));
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
assertThat(status.processed()).isEqualTo(1);
assertThat(status.skipped()).isEqualTo(1);
assertThat(status.failed()).isZero();
}
@Test
void runBackfillAsync_continuesAfterFailureAndCountsIt() {
Document a = doc();
Document b = doc();
Document c = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b, c));
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED);
when(thumbnailService.generate(c)).thenReturn(ThumbnailService.Outcome.SUCCESS);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
assertThat(status.processed()).isEqualTo(2);
assertThat(status.failed()).isEqualTo(1);
verify(thumbnailService, times(3)).generate(any());
}
@Test
void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() {
Document a = doc();
Document b = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b));
when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom"));
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.state()).isEqualTo(ThumbnailBackfillService.State.DONE);
assertThat(status.processed()).isEqualTo(1);
assertThat(status.failed()).isEqualTo(1);
}
@Test
void runBackfillAsync_rejectsConcurrentStart() {
// Force state=RUNNING via reflection
ThumbnailBackfillService.BackfillStatus running = new ThumbnailBackfillService.BackfillStatus(
ThumbnailBackfillService.State.RUNNING, "running", 10, 5, 0, 0, LocalDateTime.now());
ReflectionTestUtils.setField(backfillService, "currentStatus", running);
assertThatThrownBy(() -> backfillService.runBackfillAsync())
.isInstanceOf(DomainException.class)
.satisfies(ex -> assertThat(((DomainException) ex).getCode())
.isEqualTo(ErrorCode.THUMBNAIL_BACKFILL_ALREADY_RUNNING));
}
@Test
void runBackfillAsync_setsStartedAtAndMessage() {
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(doc()));
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
LocalDateTime before = LocalDateTime.now().minusSeconds(1);
backfillService.runBackfillAsync();
ThumbnailBackfillService.BackfillStatus status = backfillService.getStatus();
assertThat(status.startedAt()).isAfter(before);
assertThat(status.message()).isNotBlank();
}
private Document doc() {
return Document.builder()
.id(UUID.randomUUID())
.title("t")
.originalFilename("f.pdf")
.filePath("documents/f.pdf")
.contentType("application/pdf")
.build();
}
}