fix(backend): resolve cross-domain repo + controller→repo violations (#417) #420

Merged
marcel merged 9 commits from feat/issue-417-resolve-layering-violations into main 2026-05-05 10:50:04 +02:00
38 changed files with 549 additions and 238 deletions

1
backend/lombok.config Normal file
View File

@@ -0,0 +1 @@
lombok.copyableAnnotations += org.springframework.context.annotation.Lazy

View File

@@ -1,8 +1,8 @@
package org.raddatz.familienarchiv.controller; package org.raddatz.familienarchiv.controller;
import java.time.LocalDateTime; import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository; import org.raddatz.familienarchiv.service.PasswordResetTestHelper;
import org.springframework.context.annotation.Profile; import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -10,10 +10,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
/** /**
* Test-only endpoint to retrieve a password reset token by email. * Test-only endpoint to retrieve a password reset token by email.
* Only active under the "e2e" Spring profile. * Only active under the "e2e" Spring profile.
@@ -24,14 +20,14 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthE2EController { public class AuthE2EController {
private final PasswordResetTokenRepository tokenRepository; private final PasswordResetTestHelper passwordResetTestHelper;
// Hidden from the OpenAPI spec — this endpoint must never appear in the generated api.ts // Hidden from the OpenAPI spec — this endpoint must never appear in the generated api.ts
// even when the e2e profile is active alongside the dev profile during spec generation. // even when the e2e profile is active alongside the dev profile during spec generation.
@Operation(hidden = true) @Operation(hidden = true)
@GetMapping("/reset-token-for-test") @GetMapping("/reset-token-for-test")
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) { public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now()) return passwordResetTestHelper.getResetTokenForTest(email)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }

View File

@@ -1,25 +1,25 @@
package org.raddatz.familienarchiv.controller; package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.StatsDTO; import org.raddatz.familienarchiv.dto.StatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.repository.PersonRepository; import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.StatsService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
@RestController @RestController
@RequestMapping("/api/stats") @RequestMapping("/api/stats")
@RequiredArgsConstructor @RequiredArgsConstructor
public class StatsController { public class StatsController {
private final PersonRepository personRepository; private final StatsService statsService;
private final DocumentRepository documentRepository;
@RequirePermission(Permission.READ_ALL)
@GetMapping @GetMapping
public ResponseEntity<StatsDTO> getStats() { public ResponseEntity<StatsDTO> getStats() {
return ResponseEntity.ok(new StatsDTO(personRepository.count(), documentRepository.count())); return ResponseEntity.ok(statsService.getStats());
} }
} }

View File

@@ -10,13 +10,14 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository; import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.springframework.context.annotation.Lazy;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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
@@ -25,13 +26,31 @@ import java.util.UUID;
public class AnnotationService { public class AnnotationService {
private final AnnotationRepository annotationRepository; private final AnnotationRepository annotationRepository;
private final TranscriptionBlockRepository blockRepository; // @Lazy: AnnotationService and TranscriptionService have a mutual cleanup
// dependency (deleting an annotation cascades to its blocks; deleting a block
// cascades to its annotation). Lazy resolution lets Spring construct both beans.
@Lazy
private final TranscriptionService transcriptionService;
private final AuditService auditService; private final AuditService auditService;
public List<DocumentAnnotation> listAnnotations(UUID documentId) { public List<DocumentAnnotation> listAnnotations(UUID documentId) {
return annotationRepository.findByDocumentId(documentId); return annotationRepository.findByDocumentId(documentId);
} }
public Optional<DocumentAnnotation> findById(UUID id) {
return annotationRepository.findById(id);
}
@Transactional
public void deleteById(UUID annotationId) {
annotationRepository.deleteById(annotationId);
}
@Transactional
public void deleteAllById(java.util.Collection<UUID> annotationIds) {
annotationRepository.deleteAllById(annotationIds);
}
@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()
@@ -103,7 +122,7 @@ public class AnnotationService {
throw DomainException.forbidden("Only the annotation author can delete it"); throw DomainException.forbidden("Only the annotation author can delete it");
} }
blockRepository.deleteByAnnotationId(annotationId); transcriptionService.deleteByAnnotationId(annotationId);
annotationRepository.delete(annotation); annotationRepository.delete(annotation);
} }

View File

@@ -25,6 +25,7 @@ import org.raddatz.familienarchiv.model.TrainingLabel;
import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -69,10 +70,54 @@ public class DocumentService {
private final AuditService auditService; private final AuditService auditService;
private final TranscriptionBlockQueryService transcriptionBlockQueryService; private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AuditLogQueryService auditLogQueryService; 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; private final ThumbnailAsyncRunner thumbnailAsyncRunner;
public record StoreResult(Document document, boolean isNew) {} public record StoreResult(Document document, boolean isNew) {}
public long count() {
return documentRepository.count();
}
public Optional<Document> findById(UUID id) {
return documentRepository.findById(id);
}
public List<Document> findForThumbnailBackfill() {
return documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull();
}
public Document updateThumbnailMetadata(Document doc) {
return documentRepository.save(doc);
}
public Optional<Document> findByOriginalFilename(String originalFilename) {
return documentRepository.findByOriginalFilename(originalFilename);
}
public Document save(Document doc) {
return documentRepository.save(doc);
}
public List<org.raddatz.familienarchiv.repository.TranscriptionQueueProjection> findSegmentationQueue(int limit) {
return documentRepository.findSegmentationQueue(limit);
}
public List<org.raddatz.familienarchiv.repository.TranscriptionQueueProjection> findTranscriptionQueue(int limit) {
return documentRepository.findTranscriptionQueue(limit);
}
public List<org.raddatz.familienarchiv.repository.TranscriptionQueueProjection> findReadyToReadQueue(int limit) {
return documentRepository.findReadyToReadQueue(limit);
}
public org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection findWeeklyStats() {
return documentRepository.findWeeklyStats();
}
public Map<UUID, String> findTitlesByIds(Collection<UUID> ids) { public Map<UUID, String> findTitlesByIds(Collection<UUID> ids) {
if (ids.isEmpty()) return Map.of(); if (ids.isEmpty()) return Map.of();
Map<UUID, String> titles = new HashMap<>(); Map<UUID, String> titles = new HashMap<>();

View File

@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -55,7 +54,7 @@ public class MassImportService {
return currentStatus; return currentStatus;
} }
private final DocumentRepository documentRepository; private final DocumentService documentService;
private final PersonService personService; private final PersonService personService;
private final TagService tagService; private final TagService tagService;
private final S3Client s3Client; private final S3Client s3Client;
@@ -257,7 +256,7 @@ public class MassImportService {
@Transactional @Transactional
protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) { protected void importSingleDocument(List<String> cells, Optional<File> file, String originalFilename, String index) {
Optional<Document> existing = documentRepository.findByOriginalFilename(originalFilename); Optional<Document> existing = documentService.findByOriginalFilename(originalFilename);
if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) { if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename); log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
return; return;
@@ -333,7 +332,7 @@ public class MassImportService {
if (tag != null) doc.getTags().add(tag); if (tag != null) doc.getTags().add(tag);
doc.setMetadataComplete(metadataComplete); doc.setMetadataComplete(metadataComplete);
Document saved = documentRepository.save(doc); Document saved = documentService.save(doc);
if (file.isPresent()) { if (file.isPresent()) {
thumbnailAsyncRunner.dispatchAfterCommit(saved.getId()); thumbnailAsyncRunner.dispatchAfterCommit(saved.getId());
} }

View File

@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.model.OcrTrainingRun;
import org.raddatz.familienarchiv.model.SenderModel; import org.raddatz.familienarchiv.model.SenderModel;
import org.raddatz.familienarchiv.model.TrainingStatus; import org.raddatz.familienarchiv.model.TrainingStatus;
import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.slf4j.MDC; import org.slf4j.MDC;
import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
@@ -37,7 +36,7 @@ public class OcrTrainingService {
private final SegmentationTrainingExportService segmentationTrainingExportService; private final SegmentationTrainingExportService segmentationTrainingExportService;
private final OcrClient ocrClient; private final OcrClient ocrClient;
private final OcrHealthClient ocrHealthClient; private final OcrHealthClient ocrHealthClient;
private final TranscriptionBlockRepository blockRepository; private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final TransactionTemplate txTemplate; private final TransactionTemplate txTemplate;
private final PersonService personService; private final PersonService personService;
private final SenderModelService senderModelService; private final SenderModelService senderModelService;
@@ -189,7 +188,7 @@ public class OcrTrainingService {
.distinct() .distinct()
.count(); .count();
int totalOcrBlocks = (int) blockRepository.count(); int totalOcrBlocks = (int) transcriptionBlockQueryService.count();
int availableSegBlocks = segmentationTrainingExportService.querySegmentationBlocks().size(); int availableSegBlocks = segmentationTrainingExportService.querySegmentationBlocks().size();
List<OcrTrainingRun> recentRuns = trainingRunRepository.findTop20ByOrderByCreatedAtDesc(); List<OcrTrainingRun> recentRuns = trainingRunRepository.findTop20ByOrderByCreatedAtDesc();

View File

@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken; import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository; import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -30,7 +29,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j @Slf4j
public class PasswordResetService { public class PasswordResetService {
private final AppUserRepository userRepository; private final UserService userService;
private final PasswordResetTokenRepository tokenRepository; private final PasswordResetTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@@ -49,7 +48,7 @@ public class PasswordResetService {
* If no mail sender is configured, logs a warning. * If no mail sender is configured, logs a warning.
*/ */
public void requestReset(String email, String appBaseUrl) { public void requestReset(String email, String appBaseUrl) {
Optional<AppUser> userOpt = userRepository.findByEmail(email); Optional<AppUser> userOpt = userService.findByEmailOptional(email);
if (userOpt.isEmpty()) { if (userOpt.isEmpty()) {
log.debug("Password reset requested for unknown email: {}", email); log.debug("Password reset requested for unknown email: {}", email);
return; return;
@@ -82,12 +81,21 @@ public class PasswordResetService {
AppUser user = resetToken.getUser(); AppUser user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword())); user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user); userService.save(user);
resetToken.setUsed(true); resetToken.setUsed(true);
tokenRepository.save(resetToken); tokenRepository.save(resetToken);
} }
/**
* Returns the raw token string of the most recent active (unused, unexpired)
* reset token for the given email, if any. Used by the e2e helper to drive
* automated password-reset flows; production code paths never call this.
*/
public Optional<String> findLatestActiveTokenForEmail(String email) {
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now());
}
/** Nightly cleanup of expired and used tokens. */ /** Nightly cleanup of expired and used tokens. */
@Scheduled(cron = "0 0 3 * * *") @Scheduled(cron = "0 0 3 * * *")
@Transactional @Transactional

View File

@@ -0,0 +1,24 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* E2E-only thin wrapper around {@link PasswordResetService} that exposes
* the latest active reset token for a given email. Loaded only when the
* {@code e2e} Spring profile is active so production code paths never see it.
*/
@Service
@Profile("e2e")
@RequiredArgsConstructor
public class PasswordResetTestHelper {
private final PasswordResetService passwordResetService;
public Optional<String> getResetTokenForTest(String email) {
return passwordResetService.findLatestActiveTokenForEmail(email);
}
}

View File

@@ -46,6 +46,10 @@ public class PersonService {
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id)); .orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
} }
public long count() {
return personRepository.count();
}
public List<Person> findCorrespondents(UUID personId, String q) { public List<Person> findCorrespondents(UUID personId, String q) {
if (q != null && !q.isBlank()) { if (q != null && !q.isBlank()) {
return personRepository.findCorrespondentsWithFilter(personId, q); return personRepository.findCorrespondentsWithFilter(personId, q);

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

@@ -9,7 +9,6 @@ import org.raddatz.familienarchiv.model.SenderModel;
import org.raddatz.familienarchiv.model.TrainingStatus; import org.raddatz.familienarchiv.model.TrainingStatus;
import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository;
import org.raddatz.familienarchiv.repository.SenderModelRepository; import org.raddatz.familienarchiv.repository.SenderModelRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.slf4j.MDC; import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -32,7 +31,7 @@ import java.util.UUID;
public class SenderModelService { public class SenderModelService {
private final SenderModelRepository senderModelRepository; private final SenderModelRepository senderModelRepository;
private final TranscriptionBlockRepository blockRepository; private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final OcrTrainingRunRepository trainingRunRepository; private final OcrTrainingRunRepository trainingRunRepository;
private final OcrClient ocrClient; private final OcrClient ocrClient;
private final TransactionTemplate txTemplate; private final TransactionTemplate txTemplate;
@@ -62,7 +61,7 @@ public class SenderModelService {
public OcrTrainingRun triggerManualSenderTraining(UUID personId) { public OcrTrainingRun triggerManualSenderTraining(UUID personId) {
personService.getById(personId); personService.getById(personId);
long correctedLines = blockRepository.countManualKurrentBlocksByPerson(personId); long correctedLines = transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId);
boolean runNow = runOrQueueSenderTraining(personId, (int) correctedLines); boolean runNow = runOrQueueSenderTraining(personId, (int) correctedLines);
TrainingStatus targetStatus = runNow ? TrainingStatus.RUNNING : TrainingStatus.QUEUED; TrainingStatus targetStatus = runNow ? TrainingStatus.RUNNING : TrainingStatus.QUEUED;
OcrTrainingRun run = trainingRunRepository.findFirstByPersonIdAndStatus(personId, targetStatus) OcrTrainingRun run = trainingRunRepository.findFirstByPersonIdAndStatus(personId, targetStatus)
@@ -77,7 +76,7 @@ public class SenderModelService {
@Async @Async
public void runSenderTraining(UUID personId) { public void runSenderTraining(UUID personId) {
long correctedLines = blockRepository.countManualKurrentBlocksByPerson(personId); long correctedLines = transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId);
triggerSenderTraining(personId, (int) correctedLines); triggerSenderTraining(personId, (int) correctedLines);
} }
@@ -87,7 +86,7 @@ public class SenderModelService {
*/ */
@Async @Async
public void checkAndTriggerTraining(UUID personId) { public void checkAndTriggerTraining(UUID personId) {
long correctedLines = blockRepository.countManualKurrentBlocksByPerson(personId); long correctedLines = transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId);
Optional<SenderModel> existing = senderModelRepository.findByPersonId(personId); Optional<SenderModel> existing = senderModelRepository.findByPersonId(personId);
boolean shouldActivate = existing.isEmpty() && correctedLines >= activationThreshold; boolean shouldActivate = existing.isEmpty() && correctedLines >= activationThreshold;
@@ -121,7 +120,7 @@ public class SenderModelService {
} }
if (trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).isPresent()) { if (trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING).isPresent()) {
int blockCount = (int) blockRepository.countManualKurrentBlocksByPerson(personId); int blockCount = (int) transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId);
trainingRunRepository.save(OcrTrainingRun.builder() trainingRunRepository.save(OcrTrainingRun.builder()
.status(TrainingStatus.QUEUED) .status(TrainingStatus.QUEUED)
.personId(personId) .personId(personId)
@@ -133,7 +132,7 @@ public class SenderModelService {
return false; return false;
} }
long blockCount = blockRepository.countManualKurrentBlocksByPerson(personId); long blockCount = transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId);
trainingRunRepository.save(OcrTrainingRun.builder() trainingRunRepository.save(OcrTrainingRun.builder()
.status(TrainingStatus.RUNNING) .status(TrainingStatus.RUNNING)
.personId(personId) .personId(personId)
@@ -227,7 +226,7 @@ public class SenderModelService {
if (queuedOpt != null && queuedOpt.isPresent()) { if (queuedOpt != null && queuedOpt.isPresent()) {
OcrTrainingRun promoted = queuedOpt.get(); OcrTrainingRun promoted = queuedOpt.get();
log.info("Promoting queued sender training run {} for person {}", promoted.getId(), promoted.getPersonId()); log.info("Promoting queued sender training run {} for person {}", promoted.getId(), promoted.getPersonId());
long freshCount = blockRepository.countManualKurrentBlocksByPerson(promoted.getPersonId()); long freshCount = transcriptionBlockQueryService.countManualKurrentBlocksByPerson(promoted.getPersonId());
triggerSenderTraining(promoted.getPersonId(), (int) freshCount); triggerSenderTraining(promoted.getPersonId(), (int) freshCount);
} }
} }

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.StatsDTO;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class StatsService {
private final PersonService personService;
private final DocumentService documentService;
public StatsDTO getStats() {
return new StatsDTO(personService.count(), documentService.count());
}
}

View File

@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
@@ -29,7 +28,7 @@ import java.util.concurrent.TimeoutException;
@Slf4j @Slf4j
public class ThumbnailAsyncRunner { public class ThumbnailAsyncRunner {
private final DocumentRepository documentRepository; private final DocumentService documentService;
private final ThumbnailService thumbnailService; private final ThumbnailService thumbnailService;
/** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */ /** Per-document timeout for the whole generate() call — defense against corrupt PDFs. */
@@ -60,7 +59,7 @@ public class ThumbnailAsyncRunner {
*/ */
@Async("thumbnailExecutor") @Async("thumbnailExecutor")
public void generateAsync(UUID documentId) { public void generateAsync(UUID documentId) {
Optional<Document> docOpt = documentRepository.findById(documentId); Optional<Document> docOpt = documentService.findById(documentId);
if (docOpt.isEmpty()) { if (docOpt.isEmpty()) {
log.warn("Thumbnail generation skipped: document not found id={}", documentId); log.warn("Thumbnail generation skipped: document not found id={}", documentId);
return; return;

View File

@@ -5,7 +5,6 @@ import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -37,7 +36,7 @@ public class ThumbnailBackfillService {
LocalDateTime startedAt LocalDateTime startedAt
) {} ) {}
private final DocumentRepository documentRepository; private final DocumentService documentService;
private final ThumbnailService thumbnailService; private final ThumbnailService thumbnailService;
private volatile BackfillStatus currentStatus = new BackfillStatus( private volatile BackfillStatus currentStatus = new BackfillStatus(
@@ -57,7 +56,7 @@ public class ThumbnailBackfillService {
LocalDateTime startedAt = LocalDateTime.now(); LocalDateTime startedAt = LocalDateTime.now();
List<Document> docs; List<Document> docs;
try { try {
docs = documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull(); docs = documentService.findForThumbnailBackfill();
} catch (Exception e) { } catch (Exception e) {
currentStatus = new BackfillStatus(State.FAILED, currentStatus = new BackfillStatus(State.FAILED,
"Backfill fehlgeschlagen: " + e.getMessage(), "Backfill fehlgeschlagen: " + e.getMessage(),

View File

@@ -8,7 +8,6 @@ import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer; import org.apache.pdfbox.rendering.PDFRenderer;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.RequestBody;
@@ -62,16 +61,16 @@ public class ThumbnailService {
private final FileService fileService; private final FileService fileService;
private final S3Client s3Client; private final S3Client s3Client;
private final DocumentRepository documentRepository; private final DocumentService documentService;
@Value("${app.s3.bucket}") @Value("${app.s3.bucket}")
private String bucketName; private String bucketName;
public ThumbnailService(FileService fileService, S3Client s3Client, public ThumbnailService(FileService fileService, S3Client s3Client,
DocumentRepository documentRepository) { DocumentService documentService) {
this.fileService = fileService; this.fileService = fileService;
this.s3Client = s3Client; this.s3Client = s3Client;
this.documentRepository = documentRepository; this.documentService = documentService;
} }
public Outcome generate(Document doc) { public Outcome generate(Document doc) {
@@ -167,7 +166,7 @@ public class ThumbnailService {
doc.setThumbnailGeneratedAt(LocalDateTime.now()); doc.setThumbnailGeneratedAt(LocalDateTime.now());
doc.setThumbnailAspect(result.aspect()); doc.setThumbnailAspect(result.aspect());
doc.setPageCount(result.pageCount()); doc.setPageCount(result.pageCount());
documentRepository.save(doc); documentService.updateThumbnailMetadata(doc);
return Outcome.SUCCESS; return Outcome.SUCCESS;
} catch (Exception e) { } catch (Exception e) {
// Thumbnail is already in S3 but the entity update failed. Because the S3 // Thumbnail is already in S3 but the entity update failed. Because the S3

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,24 @@ 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);
}
public long countManualKurrentBlocksByPerson(UUID personId) {
return blockRepository.countManualKurrentBlocksByPerson(personId);
}
public long count() {
return blockRepository.count();
}
} }

View File

@@ -5,7 +5,6 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO; import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO; import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection; import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -20,23 +19,23 @@ public class TranscriptionQueueService {
private static final int DEFAULT_QUEUE_SIZE = 5; private static final int DEFAULT_QUEUE_SIZE = 5;
private static final int MAX_CONTRIBUTORS = 5; private static final int MAX_CONTRIBUTORS = 5;
private final DocumentRepository documentRepository; private final DocumentService documentService;
private final AuditLogQueryService auditLogQueryService; private final AuditLogQueryService auditLogQueryService;
public List<TranscriptionQueueItemDTO> getSegmentationQueue() { public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
return enrichWithContributors(documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE)); return enrichWithContributors(documentService.findSegmentationQueue(DEFAULT_QUEUE_SIZE));
} }
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() { public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
return enrichWithContributors(documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE)); return enrichWithContributors(documentService.findTranscriptionQueue(DEFAULT_QUEUE_SIZE));
} }
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() { public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
return enrichWithContributors(documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE)); return enrichWithContributors(documentService.findReadyToReadQueue(DEFAULT_QUEUE_SIZE));
} }
public TranscriptionWeeklyStatsDTO getWeeklyStats() { public TranscriptionWeeklyStatsDTO getWeeklyStats() {
var stats = documentRepository.findWeeklyStats(); var stats = documentService.findWeeklyStats();
return new TranscriptionWeeklyStatsDTO( return new TranscriptionWeeklyStatsDTO(
stats.getSegmentationCount(), stats.getSegmentationCount(),
stats.getTranscriptionCount() stats.getTranscriptionCount()

View File

@@ -16,7 +16,6 @@ import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.model.ScriptType; import org.raddatz.familienarchiv.model.ScriptType;
import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -37,7 +36,6 @@ public class TranscriptionService {
private final TranscriptionBlockRepository blockRepository; private final TranscriptionBlockRepository blockRepository;
private final TranscriptionBlockVersionRepository versionRepository; private final TranscriptionBlockVersionRepository versionRepository;
private final AnnotationRepository annotationRepository;
private final AnnotationService annotationService; private final AnnotationService annotationService;
private final DocumentService documentService; private final DocumentService documentService;
private final SenderModelService senderModelService; private final SenderModelService senderModelService;
@@ -47,6 +45,11 @@ public class TranscriptionService {
return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId); return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
} }
@Transactional
public void deleteByAnnotationId(UUID annotationId) {
blockRepository.deleteByAnnotationId(annotationId);
}
public TranscriptionBlock getBlock(UUID documentId, UUID blockId) { public TranscriptionBlock getBlock(UUID documentId, UUID blockId) {
return blockRepository.findByIdAndDocumentId(blockId, documentId) return blockRepository.findByIdAndDocumentId(blockId, documentId)
.orElseThrow(() -> DomainException.notFound( .orElseThrow(() -> DomainException.notFound(
@@ -142,7 +145,7 @@ public class TranscriptionService {
saveVersion(saved, userId); saveVersion(saved, userId);
if (!text.equals(previousText)) { if (!text.equals(previousText)) {
Optional<DocumentAnnotation> annotation = annotationRepository.findById(block.getAnnotationId()); Optional<DocumentAnnotation> annotation = annotationService.findById(block.getAnnotationId());
int pageNumber = annotation.map(DocumentAnnotation::getPageNumber).orElse(0); int pageNumber = annotation.map(DocumentAnnotation::getPageNumber).orElse(0);
auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId, auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId,
Map.of("pageNumber", pageNumber, "blockId", saved.getId().toString())); Map.of("pageNumber", pageNumber, "blockId", saved.getId().toString()));
@@ -165,7 +168,7 @@ public class TranscriptionService {
// then delete the dependent annotation directly (no ownership check needed) // then delete the dependent annotation directly (no ownership check needed)
blockRepository.delete(block); blockRepository.delete(block);
blockRepository.flush(); blockRepository.flush();
annotationRepository.deleteById(annotationId); annotationService.deleteById(annotationId);
log.info("Deleted transcription block {} and annotation {} for document {}", log.info("Deleted transcription block {} and annotation {} for document {}",
blockId, annotationId, documentId); blockId, annotationId, documentId);
} }
@@ -181,7 +184,7 @@ public class TranscriptionService {
blockRepository.deleteAll(blocks); blockRepository.deleteAll(blocks);
blockRepository.flush(); blockRepository.flush();
annotationRepository.deleteAllById(annotationIds); annotationService.deleteAllById(annotationIds);
log.info("Bulk-deleted {} transcription blocks for document {}", blocks.size(), documentId); log.info("Bulk-deleted {} transcription blocks for document {}", blocks.size(), documentId);
} }

View File

@@ -248,6 +248,14 @@ public class UserService {
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for email: " + email)); .orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for email: " + email));
} }
public Optional<AppUser> findByEmailOptional(String email) {
return userRepository.findByEmail(email);
}
public AppUser save(AppUser user) {
return userRepository.save(user);
}
public List<AppUser> getAllUsers() { public List<AppUser> getAllUsers() {
return userRepository.findAll(); return userRepository.findAll();
} }

View File

@@ -2,10 +2,10 @@ package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig; import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.dto.StatsDTO;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.security.PermissionAspect; import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService; import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.StatsService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -25,8 +25,7 @@ class StatsControllerTest {
@Autowired MockMvc mockMvc; @Autowired MockMvc mockMvc;
@MockitoBean PersonRepository personRepository; @MockitoBean StatsService statsService;
@MockitoBean DocumentRepository documentRepository;
@MockitoBean CustomUserDetailsService customUserDetailsService; @MockitoBean CustomUserDetailsService customUserDetailsService;
@Test @Test
@@ -37,9 +36,15 @@ class StatsControllerTest {
@Test @Test
@WithMockUser @WithMockUser
void getStats_returns403_whenUserLacksReadAll() throws Exception {
mockMvc.perform(get("/api/stats"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "READ_ALL")
void getStats_returns200_withCorrectCounts() throws Exception { void getStats_returns200_withCorrectCounts() throws Exception {
when(personRepository.count()).thenReturn(4L); when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L));
when(documentRepository.count()).thenReturn(12L);
mockMvc.perform(get("/api/stats")) mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk()) .andExpect(status().isOk())
@@ -48,10 +53,9 @@ class StatsControllerTest {
} }
@Test @Test
@WithMockUser @WithMockUser(authorities = "READ_ALL")
void getStats_returns200_withZeroCounts() throws Exception { void getStats_returns200_withZeroCounts() throws Exception {
when(personRepository.count()).thenReturn(0L); when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L));
when(documentRepository.count()).thenReturn(0L);
mockMvc.perform(get("/api/stats")) mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk()) .andExpect(status().isOk())

View File

@@ -13,7 +13,6 @@ import org.raddatz.familienarchiv.dto.UpdateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository; import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.DataIntegrityViolationException;
import java.util.Map; import java.util.Map;
@@ -36,7 +35,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
class AnnotationServiceTest { class AnnotationServiceTest {
@Mock AnnotationRepository annotationRepository; @Mock AnnotationRepository annotationRepository;
@Mock TranscriptionBlockRepository blockRepository; @Mock TranscriptionService transcriptionService;
@Mock AuditService auditService; @Mock AuditService auditService;
@InjectMocks AnnotationService annotationService; @InjectMocks AnnotationService annotationService;
@@ -208,7 +207,7 @@ class AnnotationServiceTest {
annotationService.deleteAnnotation(docId, annotId, ownerId); annotationService.deleteAnnotation(docId, annotId, ownerId);
verify(blockRepository).deleteByAnnotationId(annotId); verify(transcriptionService).deleteByAnnotationId(annotId);
verify(annotationRepository).delete(annotation); verify(annotationRepository).delete(annotation);
} }
@@ -225,8 +224,8 @@ class AnnotationServiceTest {
annotationService.deleteAnnotation(docId, annotId, ownerId); annotationService.deleteAnnotation(docId, annotId, ownerId);
var inOrder = org.mockito.Mockito.inOrder(blockRepository, annotationRepository); var inOrder = org.mockito.Mockito.inOrder(transcriptionService, annotationRepository);
inOrder.verify(blockRepository).deleteByAnnotationId(annotId); inOrder.verify(transcriptionService).deleteByAnnotationId(annotId);
inOrder.verify(annotationRepository).delete(annotation); inOrder.verify(annotationRepository).delete(annotation);
} }

View File

@@ -2264,4 +2264,67 @@ class DocumentServiceTest {
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder"); assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation"); 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);
}
// ─── findByOriginalFilename ────────────────────────────────────────────────
@Test
void findByOriginalFilename_returnsRepositoryResult() {
Document doc = Document.builder().id(UUID.randomUUID()).title("T").build();
when(documentRepository.findByOriginalFilename("scan.pdf")).thenReturn(Optional.of(doc));
assertThat(documentService.findByOriginalFilename("scan.pdf")).contains(doc);
}
// ─── save ──────────────────────────────────────────────────────────────────
@Test
void save_delegatesToRepository() {
Document doc = Document.builder().id(UUID.randomUUID()).title("T").build();
when(documentRepository.save(doc)).thenReturn(doc);
assertThat(documentService.save(doc)).isEqualTo(doc);
verify(documentRepository).save(doc);
}
} }

View File

@@ -11,7 +11,6 @@ import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag; import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
@@ -35,7 +34,7 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class MassImportServiceTest { class MassImportServiceTest {
@Mock DocumentRepository documentRepository; @Mock DocumentService documentService;
@Mock PersonService personService; @Mock PersonService personService;
@Mock TagService tagService; @Mock TagService tagService;
@Mock S3Client s3Client; @Mock S3Client s3Client;
@@ -45,7 +44,7 @@ class MassImportServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new MassImportService(documentRepository, personService, tagService, s3Client, thumbnailAsyncRunner); service = new MassImportService(documentService, personService, tagService, s3Client, thumbnailAsyncRunner);
ReflectionTestUtils.setField(service, "bucketName", "test-bucket"); ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
ReflectionTestUtils.setField(service, "colIndex", 0); ReflectionTestUtils.setField(service, "colIndex", 0);
ReflectionTestUtils.setField(service, "colBox", 1); ReflectionTestUtils.setField(service, "colBox", 1);
@@ -96,23 +95,23 @@ class MassImportServiceTest {
.originalFilename("doc001.pdf") .originalFilename("doc001.pdf")
.status(DocumentStatus.UPLOADED) .status(DocumentStatus.UPLOADED)
.build(); .build();
when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing)); when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001"); service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
verify(documentRepository, never()).save(any()); verify(documentService, never()).save(any());
} }
// ─── importSingleDocument — create new document (metadata only) ─────────── // ─── importSingleDocument — create new document (metadata only) ───────────
@Test @Test
void importSingleDocument_createsNewDocument_whenNotExists() { void importSingleDocument_createsNewDocument_whenNotExists() {
when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.importSingleDocument(minimalCells("doc002.pdf"), Optional.empty(), "doc002.pdf", "doc002"); service.importSingleDocument(minimalCells("doc002.pdf"), Optional.empty(), "doc002.pdf", "doc002");
verify(documentRepository).save(argThat(d -> verify(documentService).save(argThat(d ->
d.getOriginalFilename().equals("doc002.pdf") d.getOriginalFilename().equals("doc002.pdf")
&& d.getStatus() == DocumentStatus.PLACEHOLDER)); && d.getStatus() == DocumentStatus.PLACEHOLDER));
} }
@@ -126,12 +125,12 @@ class MassImportServiceTest {
.originalFilename("existing.pdf") .originalFilename("existing.pdf")
.status(DocumentStatus.PLACEHOLDER) .status(DocumentStatus.PLACEHOLDER)
.build(); .build();
when(documentRepository.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder)); when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.importSingleDocument(minimalCells("existing.pdf"), Optional.empty(), "existing.pdf", "existing"); service.importSingleDocument(minimalCells("existing.pdf"), Optional.empty(), "existing.pdf", "existing");
verify(documentRepository).save(same(placeholder)); verify(documentService).save(same(placeholder));
} }
// ─── importSingleDocument — with file (S3 upload) ───────────────────────── // ─── importSingleDocument — with file (S3 upload) ─────────────────────────
@@ -141,14 +140,14 @@ class MassImportServiceTest {
Path tempFile = tempDir.resolve("doc003.pdf"); Path tempFile = tempDir.resolve("doc003.pdf");
Files.write(tempFile, "PDF content".getBytes()); Files.write(tempFile, "PDF content".getBytes());
when(documentRepository.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.importSingleDocument( service.importSingleDocument(
minimalCells("doc003.pdf"), Optional.of(tempFile.toFile()), "doc003.pdf", "doc003"); minimalCells("doc003.pdf"), Optional.of(tempFile.toFile()), "doc003.pdf", "doc003");
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentRepository).save(argThat(d -> d.getStatus() == DocumentStatus.UPLOADED)); verify(documentService).save(argThat(d -> d.getStatus() == DocumentStatus.UPLOADED));
} }
@Test @Test
@@ -156,42 +155,42 @@ class MassImportServiceTest {
Path tempFile = tempDir.resolve("fail.pdf"); Path tempFile = tempDir.resolve("fail.pdf");
Files.write(tempFile, "data".getBytes()); Files.write(tempFile, "data".getBytes());
when(documentRepository.findByOriginalFilename("fail.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("fail.pdf")).thenReturn(Optional.empty());
doThrow(new RuntimeException("S3 error")) doThrow(new RuntimeException("S3 error"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); .when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
service.importSingleDocument( service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail"); minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentRepository, never()).save(any()); verify(documentService, never()).save(any());
} }
// ─── importSingleDocument — sender handling ─────────────────────────────── // ─── importSingleDocument — sender handling ───────────────────────────────
@Test @Test
void importSingleDocument_setsNullSender_whenSenderCellIsBlank() { void importSingleDocument_setsNullSender_whenSenderCellIsBlank() {
when(documentRepository.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("nosender.pdf", "", "", ""); List<String> cells = buildCells("nosender.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "nosender.pdf", "nosender"); service.importSingleDocument(cells, Optional.empty(), "nosender.pdf", "nosender");
verify(documentRepository).save(argThat(d -> d.getSender() == null)); verify(documentService).save(argThat(d -> d.getSender() == null));
verify(personService, never()).findOrCreateByAlias(any()); verify(personService, never()).findOrCreateByAlias(any());
} }
@Test @Test
void importSingleDocument_createsSender_whenSenderCellIsNonBlank() { void importSingleDocument_createsSender_whenSenderCellIsNonBlank() {
Person sender = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build(); Person sender = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(documentRepository.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(sender); when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(sender);
List<String> cells = buildCells("withsender.pdf", "Walter Müller", "", ""); List<String> cells = buildCells("withsender.pdf", "Walter Müller", "", "");
service.importSingleDocument(cells, Optional.empty(), "withsender.pdf", "withsender"); service.importSingleDocument(cells, Optional.empty(), "withsender.pdf", "withsender");
verify(personService).findOrCreateByAlias("Walter Müller"); verify(personService).findOrCreateByAlias("Walter Müller");
verify(documentRepository).save(argThat(d -> d.getSender() == sender)); verify(documentService).save(argThat(d -> d.getSender() == sender));
} }
// ─── importSingleDocument — tag handling ───────────────────────────────── // ─── importSingleDocument — tag handling ─────────────────────────────────
@@ -199,8 +198,8 @@ class MassImportServiceTest {
@Test @Test
void importSingleDocument_createsTag_whenTagCellIsNonBlank() { void importSingleDocument_createsTag_whenTagCellIsNonBlank() {
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build(); Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
when(documentRepository.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tagService.findOrCreate("Familie")).thenReturn(tag); when(tagService.findOrCreate("Familie")).thenReturn(tag);
List<String> cells = buildCells("tagged.pdf", "", "", "Familie"); List<String> cells = buildCells("tagged.pdf", "", "", "Familie");
@@ -211,8 +210,8 @@ class MassImportServiceTest {
@Test @Test
void importSingleDocument_doesNotCreateTag_whenTagCellIsBlank() { void importSingleDocument_doesNotCreateTag_whenTagCellIsBlank() {
when(documentRepository.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("notag.pdf", "", "", ""); List<String> cells = buildCells("notag.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "notag.pdf", "notag"); service.importSingleDocument(cells, Optional.empty(), "notag.pdf", "notag");
@@ -225,38 +224,38 @@ class MassImportServiceTest {
@Test @Test
void importSingleDocument_metadataComplete_whenSenderPresent() { void importSingleDocument_metadataComplete_whenSenderPresent() {
Person sender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build(); Person sender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
when(documentRepository.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("A B")).thenReturn(sender); when(personService.findOrCreateByAlias("A B")).thenReturn(sender);
List<String> cells = buildCells("meta.pdf", "A B", "", ""); List<String> cells = buildCells("meta.pdf", "A B", "", "");
service.importSingleDocument(cells, Optional.empty(), "meta.pdf", "meta"); service.importSingleDocument(cells, Optional.empty(), "meta.pdf", "meta");
verify(documentRepository).save(argThat(Document::isMetadataComplete)); verify(documentService).save(argThat(Document::isMetadataComplete));
} }
@Test @Test
void importSingleDocument_metadataIncomplete_whenNoKeyFieldsPresent() { void importSingleDocument_metadataIncomplete_whenNoKeyFieldsPresent() {
when(documentRepository.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("nometa.pdf", "", "", ""); List<String> cells = buildCells("nometa.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "nometa.pdf", "nometa"); service.importSingleDocument(cells, Optional.empty(), "nometa.pdf", "nometa");
verify(documentRepository).save(argThat(d -> !d.isMetadataComplete())); verify(documentService).save(argThat(d -> !d.isMetadataComplete()));
} }
// ─── importSingleDocument — blank fields set to null ───────────────────── // ─── importSingleDocument — blank fields set to null ─────────────────────
@Test @Test
void importSingleDocument_setsBlankFieldsToNull() { void importSingleDocument_setsBlankFieldsToNull() {
when(documentRepository.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("blank.pdf", "", "", ""); List<String> cells = buildCells("blank.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "blank.pdf", "blank"); service.importSingleDocument(cells, Optional.empty(), "blank.pdf", "blank");
verify(documentRepository).save(argThat(d -> verify(documentService).save(argThat(d ->
d.getLocation() == null && d.getLocation() == null &&
d.getSummary() == null && d.getSummary() == null &&
d.getTranscription() == null && d.getTranscription() == null &&
@@ -281,13 +280,13 @@ class MassImportServiceTest {
); );
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(0); assertThat(result).isEqualTo(0);
verify(documentRepository, never()).findByOriginalFilename(any()); verify(documentService, never()).findByOriginalFilename(any());
} }
@Test @Test
void processRows_addsExtension_whenIndexHasNoDot() { void processRows_addsExtension_whenIndexHasNoDot() {
when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of( List<List<String>> rows = List.of(
List.of("header"), List.of("header"),
@@ -296,13 +295,13 @@ class MassImportServiceTest {
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(1); assertThat(result).isEqualTo(1);
verify(documentRepository).findByOriginalFilename("doc001.pdf"); verify(documentService).findByOriginalFilename("doc001.pdf");
} }
@Test @Test
void processRows_usesFilenameAsIs_whenIndexHasDot() { void processRows_usesFilenameAsIs_whenIndexHasDot() {
when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of( List<List<String>> rows = List.of(
List.of("header"), List.of("header"),
@@ -311,15 +310,15 @@ class MassImportServiceTest {
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows); Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(1); assertThat(result).isEqualTo(1);
verify(documentRepository).findByOriginalFilename("doc002.pdf"); verify(documentService).findByOriginalFilename("doc002.pdf");
} }
// ─── importSingleDocument — non-blank optional fields ──────────────────── // ─── importSingleDocument — non-blank optional fields ────────────────────
@Test @Test
void importSingleDocument_setsNonNullOptionalFields_whenPresent() { void importSingleDocument_setsNonNullOptionalFields_whenPresent() {
when(documentRepository.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
// box=1, folder=2, location=9, summary=11, transcription=13 // box=1, folder=2, location=9, summary=11, transcription=13
List<String> cells = List.of( List<String> cells = List.of(
@@ -341,7 +340,7 @@ class MassImportServiceTest {
service.importSingleDocument(cells, Optional.empty(), "rich.pdf", "rich"); service.importSingleDocument(cells, Optional.empty(), "rich.pdf", "rich");
verify(documentRepository).save(argThat(d -> verify(documentService).save(argThat(d ->
"Box A".equals(d.getArchiveBox()) && "Box A".equals(d.getArchiveBox()) &&
"Folder B".equals(d.getArchiveFolder()) && "Folder B".equals(d.getArchiveFolder()) &&
"Hamburg".equals(d.getLocation()) && "Hamburg".equals(d.getLocation()) &&
@@ -352,27 +351,27 @@ class MassImportServiceTest {
@Test @Test
void importSingleDocument_setsMetadataComplete_whenReceiversArePresent() { void importSingleDocument_setsMetadataComplete_whenReceiversArePresent() {
Person receiver = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build(); Person receiver = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(documentRepository.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(receiver); when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(receiver);
List<String> cells = List.of( List<String> cells = List.of(
"rcv.pdf", "", "", "", "", "Walter Müller", "", "", "", "", "", "", "", ""); "rcv.pdf", "", "", "", "", "Walter Müller", "", "", "", "", "", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "rcv.pdf", "rcv"); service.importSingleDocument(cells, Optional.empty(), "rcv.pdf", "rcv");
verify(documentRepository).save(argThat(Document::isMetadataComplete)); verify(documentService).save(argThat(Document::isMetadataComplete));
} }
@Test @Test
void importSingleDocument_setsMetadataComplete_whenDateIsPresent() { void importSingleDocument_setsMetadataComplete_whenDateIsPresent() {
when(documentRepository.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty()); when(documentService.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = List.of( List<String> cells = List.of(
"dated.pdf", "", "", "", "", "", "", "2024-03-15", "", "", "", "", "", ""); "dated.pdf", "", "", "", "", "", "", "2024-03-15", "", "", "", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "dated.pdf", "dated"); service.importSingleDocument(cells, Optional.empty(), "dated.pdf", "dated");
verify(documentRepository).save(argThat(Document::isMetadataComplete)); verify(documentService).save(argThat(Document::isMetadataComplete));
} }
// ─── buildTitle — null location ─────────────────────────────────────────── // ─── buildTitle — null location ───────────────────────────────────────────

View File

@@ -11,7 +11,6 @@ import org.raddatz.familienarchiv.model.SenderModel;
import org.raddatz.familienarchiv.model.TrainingStatus; import org.raddatz.familienarchiv.model.TrainingStatus;
import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.service.PersonService; import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
@@ -34,7 +33,7 @@ class OcrTrainingServiceTest {
SegmentationTrainingExportService segExportService; SegmentationTrainingExportService segExportService;
OcrClient ocrClient; OcrClient ocrClient;
OcrHealthClient healthClient; OcrHealthClient healthClient;
TranscriptionBlockRepository blockRepository; TranscriptionBlockQueryService transcriptionBlockQueryService;
TransactionTemplate txTemplate; TransactionTemplate txTemplate;
PersonService personService; PersonService personService;
SenderModelService senderModelService; SenderModelService senderModelService;
@@ -47,7 +46,7 @@ class OcrTrainingServiceTest {
segExportService = mock(SegmentationTrainingExportService.class); segExportService = mock(SegmentationTrainingExportService.class);
ocrClient = mock(OcrClient.class); ocrClient = mock(OcrClient.class);
healthClient = mock(OcrHealthClient.class); healthClient = mock(OcrHealthClient.class);
blockRepository = mock(TranscriptionBlockRepository.class); transcriptionBlockQueryService = mock(TranscriptionBlockQueryService.class);
txTemplate = mock(TransactionTemplate.class); txTemplate = mock(TransactionTemplate.class);
personService = mock(PersonService.class); personService = mock(PersonService.class);
senderModelService = mock(SenderModelService.class); senderModelService = mock(SenderModelService.class);
@@ -58,9 +57,9 @@ class OcrTrainingServiceTest {
return callback.doInTransaction(null); return callback.doInTransaction(null);
}); });
service = new OcrTrainingService(runRepository, exportService, segExportService, ocrClient, healthClient, blockRepository, txTemplate, personService, senderModelService); service = new OcrTrainingService(runRepository, exportService, segExportService, ocrClient, healthClient, transcriptionBlockQueryService, txTemplate, personService, senderModelService);
when(blockRepository.count()).thenReturn(0L); when(transcriptionBlockQueryService.count()).thenReturn(0L);
when(runRepository.findTop20ByOrderByCreatedAtDesc()).thenReturn(List.of()); when(runRepository.findTop20ByOrderByCreatedAtDesc()).thenReturn(List.of());
when(segExportService.querySegmentationBlocks()).thenReturn(List.of()); when(segExportService.querySegmentationBlocks()).thenReturn(List.of());
when(senderModelService.getAllSenderModels()).thenReturn(List.of()); when(senderModelService.getAllSenderModels()).thenReturn(List.of());

View File

@@ -22,7 +22,6 @@ import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.AppUser; import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken; import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository; import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.mail.MailSendException; import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
@@ -33,7 +32,7 @@ import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class PasswordResetServiceTest { class PasswordResetServiceTest {
@Mock AppUserRepository userRepository; @Mock UserService userService;
@Mock PasswordResetTokenRepository tokenRepository; @Mock PasswordResetTokenRepository tokenRepository;
@Mock PasswordEncoder passwordEncoder; @Mock PasswordEncoder passwordEncoder;
@Mock JavaMailSender mailSender; @Mock JavaMailSender mailSender;
@@ -53,7 +52,7 @@ class PasswordResetServiceTest {
@Test @Test
void requestReset_savesTokenForKnownEmail() { void requestReset_savesTokenForKnownEmail() {
AppUser user = makeUser("user@example.com"); AppUser user = makeUser("user@example.com");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); when(userService.findByEmailOptional("user@example.com")).thenReturn(Optional.of(user));
service.requestReset("user@example.com", "http://localhost:3000"); service.requestReset("user@example.com", "http://localhost:3000");
@@ -65,7 +64,7 @@ class PasswordResetServiceTest {
@Test @Test
void requestReset_doesNothingForUnknownEmail() { void requestReset_doesNothingForUnknownEmail() {
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty()); when(userService.findByEmailOptional("ghost@example.com")).thenReturn(Optional.empty());
service.requestReset("ghost@example.com", "http://localhost:3000"); service.requestReset("ghost@example.com", "http://localhost:3000");
@@ -93,7 +92,7 @@ class PasswordResetServiceTest {
service.resetPassword(req); service.resetPassword(req);
verify(passwordEncoder).encode("newpass"); verify(passwordEncoder).encode("newpass");
verify(userRepository).save(argThat(u -> u.getPassword().equals("hashed-newpass"))); verify(userService).save(argThat(u -> u.getPassword().equals("hashed-newpass")));
assertThat(token.isUsed()).isTrue(); assertThat(token.isUsed()).isTrue();
} }
@@ -153,7 +152,7 @@ class PasswordResetServiceTest {
void requestReset_skipsEmail_whenMailSenderIsNull() { void requestReset_skipsEmail_whenMailSenderIsNull() {
ReflectionTestUtils.setField(service, "mailSender", null); ReflectionTestUtils.setField(service, "mailSender", null);
AppUser user = makeUser("user@example.com"); AppUser user = makeUser("user@example.com");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); when(userService.findByEmailOptional("user@example.com")).thenReturn(Optional.of(user));
// Must not throw even without mail sender // Must not throw even without mail sender
service.requestReset("user@example.com", "http://localhost:3000"); service.requestReset("user@example.com", "http://localhost:3000");
@@ -167,7 +166,7 @@ class PasswordResetServiceTest {
// mailSender is @Autowired(required=false) — not in constructor, so needs explicit injection // mailSender is @Autowired(required=false) — not in constructor, so needs explicit injection
ReflectionTestUtils.setField(service, "mailSender", mailSender); ReflectionTestUtils.setField(service, "mailSender", mailSender);
AppUser user = makeUser("user@example.com"); AppUser user = makeUser("user@example.com");
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); when(userService.findByEmailOptional("user@example.com")).thenReturn(Optional.of(user));
doThrow(new MailSendException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class)); doThrow(new MailSendException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
// Must not propagate the MailException // Must not propagate the MailException

View File

@@ -0,0 +1,35 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PasswordResetTestHelperTest {
@Mock PasswordResetService passwordResetService;
@InjectMocks PasswordResetTestHelper helper;
@Test
void getResetTokenForTest_returnsToken_whenPresent() {
when(passwordResetService.findLatestActiveTokenForEmail("user@example.com"))
.thenReturn(Optional.of("abc123"));
assertThat(helper.getResetTokenForTest("user@example.com")).contains("abc123");
}
@Test
void getResetTokenForTest_returnsEmpty_whenAbsent() {
when(passwordResetService.findLatestActiveTokenForEmail("ghost@example.com"))
.thenReturn(Optional.empty());
assertThat(helper.getResetTokenForTest("ghost@example.com")).isEmpty();
}
}

View File

@@ -12,7 +12,6 @@ import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository; import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository;
import org.raddatz.familienarchiv.repository.SenderModelRepository; import org.raddatz.familienarchiv.repository.SenderModelRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionCallback; import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.support.TransactionTemplate;
@@ -28,7 +27,7 @@ import static org.mockito.Mockito.*;
class SenderModelServiceTest { class SenderModelServiceTest {
SenderModelRepository senderModelRepository; SenderModelRepository senderModelRepository;
TranscriptionBlockRepository blockRepository; TranscriptionBlockQueryService transcriptionBlockQueryService;
OcrTrainingRunRepository trainingRunRepository; OcrTrainingRunRepository trainingRunRepository;
OcrClient ocrClient; OcrClient ocrClient;
TransactionTemplate txTemplate; TransactionTemplate txTemplate;
@@ -42,7 +41,7 @@ class SenderModelServiceTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
senderModelRepository = mock(SenderModelRepository.class); senderModelRepository = mock(SenderModelRepository.class);
blockRepository = mock(TranscriptionBlockRepository.class); transcriptionBlockQueryService = mock(TranscriptionBlockQueryService.class);
trainingRunRepository = mock(OcrTrainingRunRepository.class); trainingRunRepository = mock(OcrTrainingRunRepository.class);
ocrClient = mock(OcrClient.class); ocrClient = mock(OcrClient.class);
txTemplate = mock(TransactionTemplate.class); txTemplate = mock(TransactionTemplate.class);
@@ -57,7 +56,7 @@ class SenderModelServiceTest {
return callback.doInTransaction(null); return callback.doInTransaction(null);
}); });
service = new SenderModelService(senderModelRepository, blockRepository, service = new SenderModelService(senderModelRepository, transcriptionBlockQueryService,
trainingRunRepository, ocrClient, txTemplate, trainingDataExportService, personService); trainingRunRepository, ocrClient, txTemplate, trainingDataExportService, personService);
ReflectionTestUtils.setField(service, "self", selfProxy); ReflectionTestUtils.setField(service, "self", selfProxy);
ReflectionTestUtils.setField(service, "activationThreshold", 100); ReflectionTestUtils.setField(service, "activationThreshold", 100);
@@ -82,7 +81,7 @@ class SenderModelServiceTest {
@Test @Test
void runSenderTraining_queriesBlockCountForPerson() { void runSenderTraining_queriesBlockCountForPerson() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(42L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(42L);
// triggerSenderTraining needs a RUNNING row — return empty to abort early // triggerSenderTraining needs a RUNNING row — return empty to abort early
when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING)) when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING))
.thenReturn(Optional.empty()); .thenReturn(Optional.empty());
@@ -93,14 +92,14 @@ class SenderModelServiceTest {
// triggerSenderTraining will throw when no RUNNING row found // triggerSenderTraining will throw when no RUNNING row found
} }
verify(blockRepository).countManualKurrentBlocksByPerson(personId); verify(transcriptionBlockQueryService).countManualKurrentBlocksByPerson(personId);
} }
// ─── Activation threshold ───────────────────────────────────────────────── // ─── Activation threshold ─────────────────────────────────────────────────
@Test @Test
void checkAndTriggerTraining_doesNothing_belowActivationThreshold() { void checkAndTriggerTraining_doesNothing_belowActivationThreshold() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(99L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(99L);
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty()); when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty());
SenderModelService spy = spy(service); SenderModelService spy = spy(service);
@@ -111,7 +110,7 @@ class SenderModelServiceTest {
@Test @Test
void checkAndTriggerTraining_triggersTraining_atActivationThreshold() { void checkAndTriggerTraining_triggersTraining_atActivationThreshold() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(100L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty()); when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty());
SenderModelService spy = spy(service); SenderModelService spy = spy(service);
@@ -129,7 +128,7 @@ class SenderModelServiceTest {
SenderModel existing = SenderModel.builder().personId(personId) SenderModel existing = SenderModel.builder().personId(personId)
.correctedLinesAtTraining(100).build(); .correctedLinesAtTraining(100).build();
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.of(existing)); when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.of(existing));
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(149L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(149L);
SenderModelService spy = spy(service); SenderModelService spy = spy(service);
spy.checkAndTriggerTraining(personId); spy.checkAndTriggerTraining(personId);
@@ -142,7 +141,7 @@ class SenderModelServiceTest {
SenderModel existing = SenderModel.builder().personId(personId) SenderModel existing = SenderModel.builder().personId(personId)
.correctedLinesAtTraining(100).build(); .correctedLinesAtTraining(100).build();
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.of(existing)); when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.of(existing));
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(150L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(150L);
SenderModelService spy = spy(service); SenderModelService spy = spy(service);
doReturn(false).when(spy).runOrQueueSenderTraining(personId, 150); doReturn(false).when(spy).runOrQueueSenderTraining(personId, 150);
@@ -156,7 +155,7 @@ class SenderModelServiceTest {
@Test @Test
void checkAndTriggerTraining_callsTrigger_whenRunNow() { void checkAndTriggerTraining_callsTrigger_whenRunNow() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(100L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty()); when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty());
SenderModelService spy = spy(service); SenderModelService spy = spy(service);
@@ -170,7 +169,7 @@ class SenderModelServiceTest {
@Test @Test
void checkAndTriggerTraining_doesNotCallTrigger_whenQueued() { void checkAndTriggerTraining_doesNotCallTrigger_whenQueued() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(100L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty()); when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty());
SenderModelService spy = spy(service); SenderModelService spy = spy(service);
@@ -200,7 +199,7 @@ class SenderModelServiceTest {
when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn( when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(
Optional.of(OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.RUNNING) Optional.of(OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.RUNNING)
.blockCount(5).documentCount(1).modelName("german_kurrent").build())); .blockCount(5).documentCount(1).modelName("german_kurrent").build()));
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(120L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(120L);
when(trainingRunRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(trainingRunRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
boolean result = service.runOrQueueSenderTraining(personId, 120); boolean result = service.runOrQueueSenderTraining(personId, 120);
@@ -226,7 +225,7 @@ class SenderModelServiceTest {
// eliminating the race window between the check and a separate triggerSenderTraining call. // eliminating the race window between the check and a separate triggerSenderTraining call.
when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false); when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false);
when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty());
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(120L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(120L);
when(trainingRunRepository.save(any())).thenAnswer(inv -> { when(trainingRunRepository.save(any())).thenAnswer(inv -> {
OcrTrainingRun r = inv.getArgument(0); OcrTrainingRun r = inv.getArgument(0);
if (r.getId() == null) r.setId(UUID.randomUUID()); if (r.getId() == null) r.setId(UUID.randomUUID());
@@ -314,7 +313,7 @@ class SenderModelServiceTest {
@Test @Test
void triggerManualSenderTraining_returnsRunningRun_whenIdle() { void triggerManualSenderTraining_returnsRunningRun_whenIdle() {
when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build()); when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build());
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(0L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(0L);
when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false); when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false);
when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty());
OcrTrainingRun runningRun = OcrTrainingRun.builder() OcrTrainingRun runningRun = OcrTrainingRun.builder()
@@ -333,7 +332,7 @@ class SenderModelServiceTest {
@Test @Test
void triggerManualSenderTraining_returnsQueuedRun_whenAnotherRunning() { void triggerManualSenderTraining_returnsQueuedRun_whenAnotherRunning() {
when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build()); when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build());
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(0L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(0L);
when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false); when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false);
when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn( when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(
Optional.of(OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.RUNNING) Optional.of(OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.RUNNING)
@@ -363,7 +362,7 @@ class SenderModelServiceTest {
@Test @Test
void triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate() { void triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate() {
when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build()); when(personService.getById(personId)).thenReturn(Person.builder().id(personId).build());
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(0L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(0L);
when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false); when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false);
when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty()); when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty());
OcrTrainingRun runningRun = OcrTrainingRun.builder() OcrTrainingRun runningRun = OcrTrainingRun.builder()
@@ -405,7 +404,7 @@ class SenderModelServiceTest {
.modelName("sender_" + nextPersonId).build(); .modelName("sender_" + nextPersonId).build();
when(trainingRunRepository.findFirstByStatusOrderByCreatedAtAsc(TrainingStatus.QUEUED)) when(trainingRunRepository.findFirstByStatusOrderByCreatedAtAsc(TrainingStatus.QUEUED))
.thenReturn(Optional.of(queued)); .thenReturn(Optional.of(queued));
when(blockRepository.countManualKurrentBlocksByPerson(nextPersonId)).thenReturn(5L); when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(nextPersonId)).thenReturn(5L);
SenderModelService spy = spy(service); SenderModelService spy = spy(service);
// Stub the recursive call to stop the chain after one promotion // Stub the recursive call to stop the chain after one promotion

View File

@@ -0,0 +1,41 @@
package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.raddatz.familienarchiv.dto.StatsDTO;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StatsServiceTest {
@Mock PersonService personService;
@Mock DocumentService documentService;
@InjectMocks StatsService statsService;
@Test
void getStats_returnsCountsFromServices() {
when(personService.count()).thenReturn(4L);
when(documentService.count()).thenReturn(12L);
StatsDTO stats = statsService.getStats();
assertThat(stats.totalPersons()).isEqualTo(4L);
assertThat(stats.totalDocuments()).isEqualTo(12L);
}
@Test
void getStats_returnsZero_whenNoEntities() {
when(personService.count()).thenReturn(0L);
when(documentService.count()).thenReturn(0L);
StatsDTO stats = statsService.getStats();
assertThat(stats.totalPersons()).isZero();
assertThat(stats.totalDocuments()).isZero();
}
}

View File

@@ -4,7 +4,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager; import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -18,22 +17,22 @@ import static org.mockito.Mockito.*;
class ThumbnailAsyncRunnerTest { class ThumbnailAsyncRunnerTest {
private DocumentRepository documentRepository; private DocumentService documentService;
private ThumbnailService thumbnailService; private ThumbnailService thumbnailService;
private ThumbnailAsyncRunner runner; private ThumbnailAsyncRunner runner;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
documentRepository = mock(DocumentRepository.class); documentService = mock(DocumentService.class);
thumbnailService = mock(ThumbnailService.class); thumbnailService = mock(ThumbnailService.class);
runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService); runner = new ThumbnailAsyncRunner(documentService, thumbnailService);
} }
@Test @Test
void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() { void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); 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); runner.dispatchAfterCommit(id);
@@ -44,7 +43,7 @@ class ThumbnailAsyncRunnerTest {
void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() { void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); 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(); TransactionSynchronizationManager.initSynchronization();
try { try {
@@ -69,7 +68,7 @@ class ThumbnailAsyncRunnerTest {
void dispatchAfterCommit_whenRollback_doesNotDispatch() { void dispatchAfterCommit_whenRollback_doesNotDispatch() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); 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(); TransactionSynchronizationManager.initSynchronization();
try { try {
@@ -88,7 +87,7 @@ class ThumbnailAsyncRunnerTest {
@Test @Test
void generateAsync_skipsWhenDocumentMissing() { void generateAsync_skipsWhenDocumentMissing() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty()); when(documentService.findById(id)).thenReturn(Optional.empty());
runner.generateAsync(id); runner.generateAsync(id);
@@ -99,7 +98,7 @@ class ThumbnailAsyncRunnerTest {
void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception { void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build(); 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 // generate sleeps longer than the timeout — simulates a hung PDFBox render
when(thumbnailService.generate(doc)).thenAnswer(inv -> { when(thumbnailService.generate(doc)).thenAnswer(inv -> {
Thread.sleep(5_000); 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.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -19,15 +18,15 @@ import static org.mockito.Mockito.*;
class ThumbnailBackfillServiceTest { class ThumbnailBackfillServiceTest {
private DocumentRepository documentRepository; private DocumentService documentService;
private ThumbnailService thumbnailService; private ThumbnailService thumbnailService;
private ThumbnailBackfillService backfillService; private ThumbnailBackfillService backfillService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
documentRepository = mock(DocumentRepository.class); documentService = mock(DocumentService.class);
thumbnailService = mock(ThumbnailService.class); thumbnailService = mock(ThumbnailService.class);
backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService); backfillService = new ThumbnailBackfillService(documentService, thumbnailService);
} }
@Test @Test
@@ -45,7 +44,7 @@ class ThumbnailBackfillServiceTest {
Document a = doc(); Document a = doc();
Document b = doc(); Document b = doc();
Document c = doc(); Document c = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(a, b, c)); .thenReturn(List.of(a, b, c));
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS); when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
@@ -64,7 +63,7 @@ class ThumbnailBackfillServiceTest {
void runBackfillAsync_countsSkippedSeparately() { void runBackfillAsync_countsSkippedSeparately() {
Document a = doc(); Document a = doc();
Document b = doc(); Document b = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(a, b)); .thenReturn(List.of(a, b));
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS); when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED); when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED);
@@ -83,7 +82,7 @@ class ThumbnailBackfillServiceTest {
Document a = doc(); Document a = doc();
Document b = doc(); Document b = doc();
Document c = doc(); Document c = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(a, b, c)); .thenReturn(List.of(a, b, c));
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS); when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED); when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED);
@@ -102,7 +101,7 @@ class ThumbnailBackfillServiceTest {
void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() { void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() {
Document a = doc(); Document a = doc();
Document b = doc(); Document b = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(a, b)); .thenReturn(List.of(a, b));
when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom")); when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom"));
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS); when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS);
@@ -130,7 +129,7 @@ class ThumbnailBackfillServiceTest {
@Test @Test
void runBackfillAsync_setsStartedAtAndMessage() { void runBackfillAsync_setsStartedAtAndMessage() {
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull()) when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(doc())); .thenReturn(List.of(doc()));
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS); 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.Document;
import org.raddatz.familienarchiv.model.DocumentStatus; import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.ThumbnailAspect; import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
@@ -39,17 +38,17 @@ class ThumbnailServiceTest {
private FileService fileService; private FileService fileService;
private S3Client s3Client; private S3Client s3Client;
private DocumentRepository documentRepository; private DocumentService documentService;
private ThumbnailService thumbnailService; private ThumbnailService thumbnailService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
fileService = mock(FileService.class); fileService = mock(FileService.class);
s3Client = mock(S3Client.class); s3Client = mock(S3Client.class);
documentRepository = mock(DocumentRepository.class); documentService = mock(DocumentService.class);
thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository); thumbnailService = new ThumbnailService(fileService, s3Client, documentService);
ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket"); 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 @Test
@@ -103,7 +102,7 @@ class ThumbnailServiceTest {
assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg"); assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
assertThat(doc.getThumbnailGeneratedAt()).isNotNull(); assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
verify(documentRepository).save(doc); verify(documentService).updateThumbnailMetadata(doc);
} }
@Test @Test
@@ -152,7 +151,7 @@ class ThumbnailServiceTest {
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
assertThat(doc.getThumbnailKey()).isNull(); assertThat(doc.getThumbnailKey()).isNull();
verify(documentRepository, never()).save(any()); verify(documentService, never()).updateThumbnailMetadata(any());
} }
@Test @Test
@@ -165,7 +164,7 @@ class ThumbnailServiceTest {
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client); verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any()); verify(documentService, never()).updateThumbnailMetadata(any());
} }
@Test @Test
@@ -260,7 +259,7 @@ class ThumbnailServiceTest {
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client); verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any()); verify(documentService, never()).updateThumbnailMetadata(any());
} }
@Test @Test
@@ -275,7 +274,7 @@ class ThumbnailServiceTest {
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client); verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any()); verify(documentService, never()).updateThumbnailMetadata(any());
} }
@Test @Test
@@ -286,14 +285,14 @@ class ThumbnailServiceTest {
Document doc = makeDoc("application/pdf", "documents/letter.pdf"); Document doc = makeDoc("application/pdf", "documents/letter.pdf");
when(fileService.downloadFileStream(anyString())) when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePdf())); .thenReturn(new ByteArrayInputStream(createSamplePdf()));
when(documentRepository.save(any())) when(documentService.updateThumbnailMetadata(any()))
.thenThrow(new RuntimeException("constraint violation")); .thenThrow(new RuntimeException("constraint violation"));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc); ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED); assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentRepository).save(any()); verify(documentService).updateThumbnailMetadata(any());
} }
// ─── helpers ────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────

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,33 @@ 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 real-database fidelity without pulling the full 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)

View File

@@ -10,7 +10,6 @@ import org.raddatz.familienarchiv.audit.ActivityActorDTO;
import org.raddatz.familienarchiv.audit.AuditLogQueryService; import org.raddatz.familienarchiv.audit.AuditLogQueryService;
import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO; import org.raddatz.familienarchiv.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO; import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection; import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection; import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection;
@@ -26,7 +25,7 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class TranscriptionQueueServiceTest { class TranscriptionQueueServiceTest {
@Mock DocumentRepository documentRepository; @Mock DocumentService documentService;
@Mock AuditLogQueryService auditLogQueryService; @Mock AuditLogQueryService auditLogQueryService;
@InjectMocks TranscriptionQueueService service; @InjectMocks TranscriptionQueueService service;
@@ -41,11 +40,11 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_delegatesToRepositoryWithDefaultSize() { void getSegmentationQueue_delegatesToRepositoryWithDefaultSize() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(id, "Brief von 1920", null, 0, 0, 0); TranscriptionQueueProjection proj = mockQueueProjection(id, "Brief von 1920", null, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj)); when(documentService.findSegmentationQueue(5)).thenReturn(List.of(proj));
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue(); List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
verify(documentRepository).findSegmentationQueue(5); verify(documentService).findSegmentationQueue(5);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).id()).isEqualTo(id); assertThat(result.get(0).id()).isEqualTo(id);
assertThat(result.get(0).title()).isEqualTo("Brief von 1920"); assertThat(result.get(0).title()).isEqualTo("Brief von 1920");
@@ -55,7 +54,7 @@ class TranscriptionQueueServiceTest {
@Test @Test
void getSegmentationQueue_returnsEmptyList_whenQueueIsEmpty() { void getSegmentationQueue_returnsEmptyList_whenQueueIsEmpty() {
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of()); when(documentService.findSegmentationQueue(5)).thenReturn(List.of());
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue(); List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
@@ -67,7 +66,7 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_returnsAllFive_andHasMoreFalse_whenExactlyFiveContributors() { void getSegmentationQueue_returnsAllFive_andHasMoreFalse_whenExactlyFiveContributors() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0); TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj)); when(documentService.findSegmentationQueue(5)).thenReturn(List.of(proj));
List<ActivityActorDTO> fiveActors = List.of( List<ActivityActorDTO> fiveActors = List.of(
new ActivityActorDTO("A1", "#111", "Alice One"), new ActivityActorDTO("A1", "#111", "Alice One"),
@@ -89,7 +88,7 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_mapsDocumentDateWhenPresent() { void getSegmentationQueue_mapsDocumentDateWhenPresent() {
LocalDate date = LocalDate.of(1920, 6, 15); LocalDate date = LocalDate.of(1920, 6, 15);
TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Brief", date, 0, 0, 0); TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Brief", date, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj)); when(documentService.findSegmentationQueue(5)).thenReturn(List.of(proj));
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue(); List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
@@ -102,11 +101,11 @@ class TranscriptionQueueServiceTest {
void getTranscriptionQueue_delegatesToRepositoryWithDefaultSize() { void getTranscriptionQueue_delegatesToRepositoryWithDefaultSize() {
UUID id = UUID.randomUUID(); UUID id = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(id, "Tagebuch", LocalDate.of(1943, 1, 1), 3, 1, 0); TranscriptionQueueProjection proj = mockQueueProjection(id, "Tagebuch", LocalDate.of(1943, 1, 1), 3, 1, 0);
when(documentRepository.findTranscriptionQueue(5)).thenReturn(List.of(proj)); when(documentService.findTranscriptionQueue(5)).thenReturn(List.of(proj));
List<TranscriptionQueueItemDTO> result = service.getTranscriptionQueue(); List<TranscriptionQueueItemDTO> result = service.getTranscriptionQueue();
verify(documentRepository).findTranscriptionQueue(5); verify(documentService).findTranscriptionQueue(5);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).annotationCount()).isEqualTo(3); assertThat(result.get(0).annotationCount()).isEqualTo(3);
assertThat(result.get(0).textedBlockCount()).isEqualTo(1); assertThat(result.get(0).textedBlockCount()).isEqualTo(1);
@@ -118,11 +117,11 @@ class TranscriptionQueueServiceTest {
@Test @Test
void getReadyToReadQueue_delegatesToRepositoryWithDefaultSize() { void getReadyToReadQueue_delegatesToRepositoryWithDefaultSize() {
TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Urkunde", null, 4, 4, 4); TranscriptionQueueProjection proj = mockQueueProjection(UUID.randomUUID(), "Urkunde", null, 4, 4, 4);
when(documentRepository.findReadyToReadQueue(5)).thenReturn(List.of(proj)); when(documentService.findReadyToReadQueue(5)).thenReturn(List.of(proj));
List<TranscriptionQueueItemDTO> result = service.getReadyToReadQueue(); List<TranscriptionQueueItemDTO> result = service.getReadyToReadQueue();
verify(documentRepository).findReadyToReadQueue(5); verify(documentService).findReadyToReadQueue(5);
assertThat(result).hasSize(1); assertThat(result).hasSize(1);
assertThat(result.get(0).reviewedBlockCount()).isEqualTo(4); assertThat(result.get(0).reviewedBlockCount()).isEqualTo(4);
} }
@@ -132,7 +131,7 @@ class TranscriptionQueueServiceTest {
@Test @Test
void getWeeklyStats_mapsProjectionToDTO() { void getWeeklyStats_mapsProjectionToDTO() {
TranscriptionWeeklyStatsProjection proj = mockStatsProjection(3L, 7L); TranscriptionWeeklyStatsProjection proj = mockStatsProjection(3L, 7L);
when(documentRepository.findWeeklyStats()).thenReturn(proj); when(documentService.findWeeklyStats()).thenReturn(proj);
TranscriptionWeeklyStatsDTO result = service.getWeeklyStats(); TranscriptionWeeklyStatsDTO result = service.getWeeklyStats();
@@ -143,7 +142,7 @@ class TranscriptionQueueServiceTest {
@Test @Test
void getWeeklyStats_returnsZeros_whenAllCountsAreZero() { void getWeeklyStats_returnsZeros_whenAllCountsAreZero() {
TranscriptionWeeklyStatsProjection proj = mockStatsProjection(0L, 0L); TranscriptionWeeklyStatsProjection proj = mockStatsProjection(0L, 0L);
when(documentRepository.findWeeklyStats()).thenReturn(proj); when(documentService.findWeeklyStats()).thenReturn(proj);
TranscriptionWeeklyStatsDTO result = service.getWeeklyStats(); TranscriptionWeeklyStatsDTO result = service.getWeeklyStats();
@@ -157,7 +156,7 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_includesContributors_whenAuditDataPresent() { void getSegmentationQueue_includesContributors_whenAuditDataPresent() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0); TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj)); when(documentService.findSegmentationQueue(5)).thenReturn(List.of(proj));
ActivityActorDTO actor = new ActivityActorDTO("MR", "#a6dad8", "Max Raddatz"); ActivityActorDTO actor = new ActivityActorDTO("MR", "#a6dad8", "Max Raddatz");
when(auditLogQueryService.findContributorsPerDocument(List.of(docId))) when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
@@ -173,7 +172,7 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_capsContributorsAtFive_andSetsHasMoreFlag() { void getSegmentationQueue_capsContributorsAtFive_andSetsHasMoreFlag() {
UUID docId = UUID.randomUUID(); UUID docId = UUID.randomUUID();
TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0); TranscriptionQueueProjection proj = mockQueueProjection(docId, "Brief", null, 0, 0, 0);
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of(proj)); when(documentService.findSegmentationQueue(5)).thenReturn(List.of(proj));
List<ActivityActorDTO> sixActors = List.of( List<ActivityActorDTO> sixActors = List.of(
new ActivityActorDTO("A1", "#111", "Alice One"), new ActivityActorDTO("A1", "#111", "Alice One"),

View File

@@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.audit.AuditService; import org.raddatz.familienarchiv.audit.AuditService;
import org.raddatz.familienarchiv.model.BlockSource; import org.raddatz.familienarchiv.model.BlockSource;
import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
@@ -20,7 +19,6 @@ class TranscriptionServiceGuidedTest {
TranscriptionBlockRepository blockRepository; TranscriptionBlockRepository blockRepository;
TranscriptionBlockVersionRepository versionRepository; TranscriptionBlockVersionRepository versionRepository;
AnnotationRepository annotationRepository;
AnnotationService annotationService; AnnotationService annotationService;
DocumentService documentService; DocumentService documentService;
SenderModelService senderModelService; SenderModelService senderModelService;
@@ -35,14 +33,13 @@ class TranscriptionServiceGuidedTest {
void setUp() { void setUp() {
blockRepository = mock(TranscriptionBlockRepository.class); blockRepository = mock(TranscriptionBlockRepository.class);
versionRepository = mock(TranscriptionBlockVersionRepository.class); versionRepository = mock(TranscriptionBlockVersionRepository.class);
annotationRepository = mock(AnnotationRepository.class);
annotationService = mock(AnnotationService.class); annotationService = mock(AnnotationService.class);
documentService = mock(DocumentService.class); documentService = mock(DocumentService.class);
senderModelService = mock(SenderModelService.class); senderModelService = mock(SenderModelService.class);
auditService = mock(AuditService.class); auditService = mock(AuditService.class);
service = new TranscriptionService(blockRepository, versionRepository, service = new TranscriptionService(blockRepository, versionRepository,
annotationRepository, annotationService, documentService, senderModelService, auditService); annotationService, documentService, senderModelService, auditService);
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

View File

@@ -21,7 +21,6 @@ import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.ScriptType; import org.raddatz.familienarchiv.model.ScriptType;
import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository; import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
@@ -44,7 +43,6 @@ class TranscriptionServiceTest {
@Mock TranscriptionBlockRepository blockRepository; @Mock TranscriptionBlockRepository blockRepository;
@Mock TranscriptionBlockVersionRepository versionRepository; @Mock TranscriptionBlockVersionRepository versionRepository;
@Mock AnnotationRepository annotationRepository;
@Mock AnnotationService annotationService; @Mock AnnotationService annotationService;
@Mock DocumentService documentService; @Mock DocumentService documentService;
@Mock SenderModelService senderModelService; @Mock SenderModelService senderModelService;
@@ -320,7 +318,7 @@ class TranscriptionServiceTest {
verify(blockRepository).delete(block); verify(blockRepository).delete(block);
verify(blockRepository).flush(); verify(blockRepository).flush();
verify(annotationRepository).deleteById(annotId); verify(annotationService).deleteById(annotId);
} }
@Test @Test
@@ -354,7 +352,7 @@ class TranscriptionServiceTest {
verify(blockRepository).deleteAll(List.of(block1, block2)); verify(blockRepository).deleteAll(List.of(block1, block2));
verify(blockRepository).flush(); verify(blockRepository).flush();
verify(annotationRepository).deleteAllById(List.of(annId1, annId2)); verify(annotationService).deleteAllById(List.of(annId1, annId2));
} }
@Test @Test
@@ -532,7 +530,7 @@ class TranscriptionServiceTest {
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.getDocumentById(any())).thenReturn( when(documentService.getDocumentById(any())).thenReturn(
Document.builder().scriptType(ScriptType.TYPEWRITER).build()); Document.builder().scriptType(ScriptType.TYPEWRITER).build());
when(annotationRepository.findById(annotId)).thenReturn(Optional.of(annotation)); when(annotationService.findById(annotId)).thenReturn(Optional.of(annotation));
transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId); transcriptionService.updateBlock(docId, blockId, UpdateTranscriptionBlockDTO.builder().text("new text").build(), userId);

View File

@@ -58,6 +58,34 @@ class UserServiceTest {
assertThat(userService.findByEmail("admin@example.com")).isEqualTo(user); assertThat(userService.findByEmail("admin@example.com")).isEqualTo(user);
} }
// ─── findByEmailOptional ──────────────────────────────────────────────────
@Test
void findByEmailOptional_returnsEmpty_whenMissing() {
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
assertThat(userService.findByEmailOptional("ghost@example.com")).isEmpty();
}
@Test
void findByEmailOptional_returnsUser_whenFound() {
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("admin@example.com").build();
when(userRepository.findByEmail("admin@example.com")).thenReturn(Optional.of(user));
assertThat(userService.findByEmailOptional("admin@example.com")).contains(user);
}
// ─── save ─────────────────────────────────────────────────────────────────
@Test
void save_delegatesToRepository() {
AppUser user = AppUser.builder().id(UUID.randomUUID()).email("u@example.com").build();
when(userRepository.save(user)).thenReturn(user);
assertThat(userService.save(user)).isEqualTo(user);
verify(userRepository).save(user);
}
// ─── deleteUser ─────────────────────────────────────────────────────────── // ─── deleteUser ───────────────────────────────────────────────────────────
@Test @Test