refactor(training-export): route export services through owning services

SegmentationTrainingExportService and TrainingDataExportService each injected
TranscriptionBlockRepository, AnnotationRepository and DocumentRepository
directly. They now go through:

- TranscriptionBlockQueryService (extended) for the three eligible-block
  queries — used over TranscriptionService to keep
  SenderModelService → TrainingDataExportService → TranscriptionService cycle-free.
- AnnotationService.findById (new) — read API on the annotation domain.
- DocumentService.findById (already added in #417 commit 3).

The TrainingDataExportServiceTest @DataJpaTest delegates the new service reads
to the real JPA repositories via Mockito stubs in the new makeService helper,
so the integration coverage stays unchanged.

Refs #417 (C6.2 violations #6 and #7).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 07:36:20 +02:00
parent 0ca95d5ad7
commit 310bb5b2d5
5 changed files with 63 additions and 29 deletions

View File

@@ -17,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Slf4j @Slf4j
@@ -32,6 +33,10 @@ public class AnnotationService {
return annotationRepository.findByDocumentId(documentId); return annotationRepository.findByDocumentId(documentId);
} }
public Optional<DocumentAnnotation> findById(UUID id) {
return annotationRepository.findById(id);
}
@Transactional @Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) { public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
DocumentAnnotation annotation = DocumentAnnotation.builder() DocumentAnnotation annotation = DocumentAnnotation.builder()

View File

@@ -8,9 +8,6 @@ import org.apache.pdfbox.rendering.PDFRenderer;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@@ -27,13 +24,13 @@ import java.util.zip.ZipOutputStream;
@Slf4j @Slf4j
public class SegmentationTrainingExportService { public class SegmentationTrainingExportService {
private final TranscriptionBlockRepository blockRepository; private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AnnotationRepository annotationRepository; private final AnnotationService annotationService;
private final DocumentRepository documentRepository; private final DocumentService documentService;
private final FileService fileService; private final FileService fileService;
public List<TranscriptionBlock> querySegmentationBlocks() { public List<TranscriptionBlock> querySegmentationBlocks() {
return blockRepository.findSegmentationBlocks(); return transcriptionBlockQueryService.findSegmentationBlocks();
} }
public StreamingResponseBody exportToZip() { public StreamingResponseBody exportToZip() {
@@ -51,14 +48,14 @@ public class SegmentationTrainingExportService {
// Pre-fetch annotations keyed by id // Pre-fetch annotations keyed by id
Map<UUID, DocumentAnnotation> annotations = new HashMap<>(); Map<UUID, DocumentAnnotation> annotations = new HashMap<>();
for (TranscriptionBlock b : blocks) { for (TranscriptionBlock b : blocks) {
annotationRepository.findById(b.getAnnotationId()) annotationService.findById(b.getAnnotationId())
.ifPresent(a -> annotations.put(a.getId(), a)); .ifPresent(a -> annotations.put(a.getId(), a));
} }
// Pre-fetch documents keyed by id // Pre-fetch documents keyed by id
Map<UUID, Document> documents = new HashMap<>(); Map<UUID, Document> documents = new HashMap<>();
for (UUID docId : byDoc.keySet()) { for (UUID docId : byDoc.keySet()) {
documentRepository.findById(docId).ifPresent(d -> documents.put(d.getId(), d)); documentService.findById(docId).ifPresent(d -> documents.put(d.getId(), d));
} }
return out -> { return out -> {

View File

@@ -8,9 +8,6 @@ import org.apache.pdfbox.rendering.PDFRenderer;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
@@ -28,13 +25,13 @@ import java.util.zip.ZipOutputStream;
@Slf4j @Slf4j
public class TrainingDataExportService { public class TrainingDataExportService {
private final TranscriptionBlockRepository blockRepository; private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AnnotationRepository annotationRepository; private final AnnotationService annotationService;
private final DocumentRepository documentRepository; private final DocumentService documentService;
private final FileService fileService; private final FileService fileService;
public List<TranscriptionBlock> queryEligibleBlocks() { public List<TranscriptionBlock> queryEligibleBlocks() {
return blockRepository.findEligibleKurrentBlocks(); return transcriptionBlockQueryService.findEligibleKurrentBlocks();
} }
public StreamingResponseBody exportToZip() { public StreamingResponseBody exportToZip() {
@@ -42,7 +39,7 @@ public class TrainingDataExportService {
} }
public List<TranscriptionBlock> queryBlocksForSender(UUID personId) { public List<TranscriptionBlock> queryBlocksForSender(UUID personId) {
return blockRepository.findManualKurrentBlocksByPerson(personId); return transcriptionBlockQueryService.findManualKurrentBlocksByPerson(personId);
} }
public StreamingResponseBody exportForSender(UUID personId) { public StreamingResponseBody exportForSender(UUID personId) {
@@ -63,14 +60,14 @@ public class TrainingDataExportService {
// Pre-fetch annotations keyed by id // Pre-fetch annotations keyed by id
Map<UUID, DocumentAnnotation> annotations = new HashMap<>(); Map<UUID, DocumentAnnotation> annotations = new HashMap<>();
for (TranscriptionBlock b : blocks) { for (TranscriptionBlock b : blocks) {
annotationRepository.findById(b.getAnnotationId()) annotationService.findById(b.getAnnotationId())
.ifPresent(a -> annotations.put(a.getId(), a)); .ifPresent(a -> annotations.put(a.getId(), a));
} }
// Pre-fetch documents keyed by id // Pre-fetch documents keyed by id
Map<UUID, Document> documents = new HashMap<>(); Map<UUID, Document> documents = new HashMap<>();
for (UUID docId : byDoc.keySet()) { for (UUID docId : byDoc.keySet()) {
documentRepository.findById(docId).ifPresent(d -> documents.put(d.getId(), d)); documentService.findById(docId).ifPresent(d -> documents.put(d.getId(), d));
} }
return out -> { return out -> {

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.service; package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.CompletionStatsRow; import org.raddatz.familienarchiv.repository.CompletionStatsRow;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -24,4 +25,16 @@ public class TranscriptionBlockQueryService {
} }
return result; return result;
} }
public List<TranscriptionBlock> findSegmentationBlocks() {
return blockRepository.findSegmentationBlocks();
}
public List<TranscriptionBlock> findEligibleKurrentBlocks() {
return blockRepository.findEligibleKurrentBlocks();
}
public List<TranscriptionBlock> findManualKurrentBlocksByPerson(UUID personId) {
return blockRepository.findManualKurrentBlocksByPerson(personId);
}
} }

View File

@@ -27,6 +27,7 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@@ -60,7 +61,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, "Liebe Mutter")); blockRepository.save(manualBlock(docId, annotId, "Liebe Mutter"));
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip(); StreamingResponseBody body = service.exportToZip();
byte[] zipBytes = stream(body); byte[] zipBytes = stream(body);
@@ -79,7 +80,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(block); blockRepository.save(block);
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip(); StreamingResponseBody body = service.exportToZip();
assertThat(zipEntryNames(stream(body))).isEmpty(); assertThat(zipEntryNames(stream(body))).isEmpty();
@@ -92,7 +93,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, "Liebe Tante")); blockRepository.save(manualBlock(docId, annotId, "Liebe Tante"));
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip(); StreamingResponseBody body = service.exportToZip();
byte[] zipBytes = stream(body); byte[] zipBytes = stream(body);
@@ -110,7 +111,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(block); blockRepository.save(block);
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip(); StreamingResponseBody body = service.exportToZip();
assertThat(zipEntryNames(stream(body))).isNotEmpty(); assertThat(zipEntryNames(stream(body))).isNotEmpty();
@@ -127,7 +128,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(block); blockRepository.save(block);
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip(); StreamingResponseBody body = service.exportToZip();
assertThat(zipEntryNames(stream(body))).isEmpty(); assertThat(zipEntryNames(stream(body))).isEmpty();
@@ -143,7 +144,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, "Zweite Zeile")); blockRepository.save(manualBlock(docId, annotId, "Zweite Zeile"));
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
byte[] zipBytes = stream(service.exportToZip()); byte[] zipBytes = stream(service.exportToZip());
var names = zipEntryNames(zipBytes); var names = zipEntryNames(zipBytes);
@@ -160,7 +161,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, expectedText)); blockRepository.save(manualBlock(docId, annotId, expectedText));
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
byte[] zipBytes = stream(service.exportToZip()); byte[] zipBytes = stream(service.exportToZip());
String xmlContent = readZipEntry(zipBytes, ".xml"); String xmlContent = readZipEntry(zipBytes, ".xml");
@@ -174,7 +175,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, "A & B < C > D")); blockRepository.save(manualBlock(docId, annotId, "A & B < C > D"));
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
byte[] zipBytes = stream(service.exportToZip()); byte[] zipBytes = stream(service.exportToZip());
String xmlContent = readZipEntry(zipBytes, ".xml"); String xmlContent = readZipEntry(zipBytes, ".xml");
@@ -196,7 +197,7 @@ class TrainingDataExportServiceTest {
when(fileService.downloadFileBytes("fail.pdf")).thenThrow(new FileService.StorageFileNotFoundException("missing")); when(fileService.downloadFileBytes("fail.pdf")).thenThrow(new FileService.StorageFileNotFoundException("missing"));
when(fileService.downloadFileBytes("ok.pdf")).thenReturn(minimalPdfBytes); when(fileService.downloadFileBytes("ok.pdf")).thenReturn(minimalPdfBytes);
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
byte[] zipBytes = stream(service.exportToZip()); byte[] zipBytes = stream(service.exportToZip());
var names = zipEntryNames(zipBytes); var names = zipEntryNames(zipBytes);
@@ -209,13 +210,34 @@ class TrainingDataExportServiceTest {
@Test @Test
void queryEligibleBlocks_returnsEmpty_whenNoEnrolledDocuments() { void queryEligibleBlocks_returnsEmpty_whenNoEnrolledDocuments() {
FileService fileService = mockFileService(); FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService); TrainingDataExportService service = makeService(fileService);
assertThat(service.queryEligibleBlocks()).isEmpty(); assertThat(service.queryEligibleBlocks()).isEmpty();
} }
// ─── helpers ───────────────────────────────────────────────────────────── // ─── helpers ─────────────────────────────────────────────────────────────
/**
* Builds the export service with mocked owning services that transparently
* delegate every read to the real JPA repositories provided by {@code @DataJpaTest}.
* Keeps the integration test green after #417's layering refactor without
* pulling the full transcription/annotation/document service trees into scope.
*/
private TrainingDataExportService makeService(FileService fileService) {
TranscriptionBlockQueryService blockQueryService = mock(TranscriptionBlockQueryService.class);
AnnotationService annotationService = mock(AnnotationService.class);
DocumentService documentService = mock(DocumentService.class);
when(blockQueryService.findEligibleKurrentBlocks())
.thenAnswer(inv -> blockRepository.findEligibleKurrentBlocks());
when(blockQueryService.findManualKurrentBlocksByPerson(any(UUID.class)))
.thenAnswer(inv -> blockRepository.findManualKurrentBlocksByPerson(inv.getArgument(0)));
when(annotationService.findById(any(UUID.class)))
.thenAnswer(inv -> annotationRepository.findById(inv.getArgument(0)));
when(documentService.findById(any(UUID.class)))
.thenAnswer(inv -> documentRepository.findById(inv.getArgument(0)));
return new TrainingDataExportService(blockQueryService, annotationService, documentService, fileService);
}
private UUID enrolledDoc(String filename) { private UUID enrolledDoc(String filename) {
Document doc = documentRepository.save(Document.builder() Document doc = documentRepository.save(Document.builder()
.title(filename).originalFilename(filename).filePath(filename) .title(filename).originalFilename(filename).filePath(filename)