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;
import java.time.LocalDateTime;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.service.PasswordResetTestHelper;
import org.springframework.context.annotation.Profile;
import org.springframework.http.ResponseEntity;
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.RestController;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
/**
* Test-only endpoint to retrieve a password reset token by email.
* Only active under the "e2e" Spring profile.
@@ -24,14 +20,14 @@ import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
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
// even when the e2e profile is active alongside the dev profile during spec generation.
@Operation(hidden = true)
@GetMapping("/reset-token-for-test")
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
return passwordResetTestHelper.getResetTokenForTest(email)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}

View File

@@ -1,25 +1,25 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.StatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.StatsService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final PersonRepository personRepository;
private final DocumentRepository documentRepository;
private final StatsService statsService;
@RequirePermission(Permission.READ_ALL)
@GetMapping
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.model.DocumentAnnotation;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@@ -25,13 +26,31 @@ import java.util.UUID;
public class AnnotationService {
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;
public List<DocumentAnnotation> listAnnotations(UUID 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
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
DocumentAnnotation annotation = DocumentAnnotation.builder()
@@ -103,7 +122,7 @@ public class AnnotationService {
throw DomainException.forbidden("Only the annotation author can delete it");
}
blockRepository.deleteByAnnotationId(annotationId);
transcriptionService.deleteByAnnotationId(annotationId);
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.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@@ -69,10 +70,54 @@ public class DocumentService {
private final AuditService auditService;
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
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;
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) {
if (ids.isEmpty()) return Map.of();
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.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -55,7 +54,7 @@ public class MassImportService {
return currentStatus;
}
private final DocumentRepository documentRepository;
private final DocumentService documentService;
private final PersonService personService;
private final TagService tagService;
private final S3Client s3Client;
@@ -257,7 +256,7 @@ public class MassImportService {
@Transactional
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) {
log.info("Dokument {} existiert bereits, überspringe.", originalFilename);
return;
@@ -333,7 +332,7 @@ public class MassImportService {
if (tag != null) doc.getTags().add(tag);
doc.setMetadataComplete(metadataComplete);
Document saved = documentRepository.save(doc);
Document saved = documentService.save(doc);
if (file.isPresent()) {
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.TrainingStatus;
import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.slf4j.MDC;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
@@ -37,7 +36,7 @@ public class OcrTrainingService {
private final SegmentationTrainingExportService segmentationTrainingExportService;
private final OcrClient ocrClient;
private final OcrHealthClient ocrHealthClient;
private final TranscriptionBlockRepository blockRepository;
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final TransactionTemplate txTemplate;
private final PersonService personService;
private final SenderModelService senderModelService;
@@ -189,7 +188,7 @@ public class OcrTrainingService {
.distinct()
.count();
int totalOcrBlocks = (int) blockRepository.count();
int totalOcrBlocks = (int) transcriptionBlockQueryService.count();
int availableSegBlocks = segmentationTrainingExportService.querySegmentationBlocks().size();
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.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -30,7 +29,7 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class PasswordResetService {
private final AppUserRepository userRepository;
private final UserService userService;
private final PasswordResetTokenRepository tokenRepository;
private final PasswordEncoder passwordEncoder;
@@ -49,7 +48,7 @@ public class PasswordResetService {
* If no mail sender is configured, logs a warning.
*/
public void requestReset(String email, String appBaseUrl) {
Optional<AppUser> userOpt = userRepository.findByEmail(email);
Optional<AppUser> userOpt = userService.findByEmailOptional(email);
if (userOpt.isEmpty()) {
log.debug("Password reset requested for unknown email: {}", email);
return;
@@ -82,12 +81,21 @@ public class PasswordResetService {
AppUser user = resetToken.getUser();
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
userRepository.save(user);
userService.save(user);
resetToken.setUsed(true);
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. */
@Scheduled(cron = "0 0 3 * * *")
@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));
}
public long count() {
return personRepository.count();
}
public List<Person> findCorrespondents(UUID personId, String q) {
if (q != null && !q.isBlank()) {
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.DocumentAnnotation;
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.web.servlet.mvc.method.annotation.StreamingResponseBody;
@@ -27,13 +24,13 @@ import java.util.zip.ZipOutputStream;
@Slf4j
public class SegmentationTrainingExportService {
private final TranscriptionBlockRepository blockRepository;
private final AnnotationRepository annotationRepository;
private final DocumentRepository documentRepository;
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AnnotationService annotationService;
private final DocumentService documentService;
private final FileService fileService;
public List<TranscriptionBlock> querySegmentationBlocks() {
return blockRepository.findSegmentationBlocks();
return transcriptionBlockQueryService.findSegmentationBlocks();
}
public StreamingResponseBody exportToZip() {
@@ -51,14 +48,14 @@ public class SegmentationTrainingExportService {
// Pre-fetch annotations keyed by id
Map<UUID, DocumentAnnotation> annotations = new HashMap<>();
for (TranscriptionBlock b : blocks) {
annotationRepository.findById(b.getAnnotationId())
annotationService.findById(b.getAnnotationId())
.ifPresent(a -> annotations.put(a.getId(), a));
}
// Pre-fetch documents keyed by id
Map<UUID, Document> documents = new HashMap<>();
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 -> {

View File

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

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

View File

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

View File

@@ -8,7 +8,6 @@ import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.core.sync.RequestBody;
@@ -62,16 +61,16 @@ public class ThumbnailService {
private final FileService fileService;
private final S3Client s3Client;
private final DocumentRepository documentRepository;
private final DocumentService documentService;
@Value("${app.s3.bucket}")
private String bucketName;
public ThumbnailService(FileService fileService, S3Client s3Client,
DocumentRepository documentRepository) {
DocumentService documentService) {
this.fileService = fileService;
this.s3Client = s3Client;
this.documentRepository = documentRepository;
this.documentService = documentService;
}
public Outcome generate(Document doc) {
@@ -167,7 +166,7 @@ public class ThumbnailService {
doc.setThumbnailGeneratedAt(LocalDateTime.now());
doc.setThumbnailAspect(result.aspect());
doc.setPageCount(result.pageCount());
documentRepository.save(doc);
documentService.updateThumbnailMetadata(doc);
return Outcome.SUCCESS;
} catch (Exception e) {
// 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.DocumentAnnotation;
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.web.servlet.mvc.method.annotation.StreamingResponseBody;
@@ -28,13 +25,13 @@ import java.util.zip.ZipOutputStream;
@Slf4j
public class TrainingDataExportService {
private final TranscriptionBlockRepository blockRepository;
private final AnnotationRepository annotationRepository;
private final DocumentRepository documentRepository;
private final TranscriptionBlockQueryService transcriptionBlockQueryService;
private final AnnotationService annotationService;
private final DocumentService documentService;
private final FileService fileService;
public List<TranscriptionBlock> queryEligibleBlocks() {
return blockRepository.findEligibleKurrentBlocks();
return transcriptionBlockQueryService.findEligibleKurrentBlocks();
}
public StreamingResponseBody exportToZip() {
@@ -42,7 +39,7 @@ public class TrainingDataExportService {
}
public List<TranscriptionBlock> queryBlocksForSender(UUID personId) {
return blockRepository.findManualKurrentBlocksByPerson(personId);
return transcriptionBlockQueryService.findManualKurrentBlocksByPerson(personId);
}
public StreamingResponseBody exportForSender(UUID personId) {
@@ -63,14 +60,14 @@ public class TrainingDataExportService {
// Pre-fetch annotations keyed by id
Map<UUID, DocumentAnnotation> annotations = new HashMap<>();
for (TranscriptionBlock b : blocks) {
annotationRepository.findById(b.getAnnotationId())
annotationService.findById(b.getAnnotationId())
.ifPresent(a -> annotations.put(a.getId(), a));
}
// Pre-fetch documents keyed by id
Map<UUID, Document> documents = new HashMap<>();
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 -> {

View File

@@ -1,6 +1,7 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.CompletionStatsRow;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.stereotype.Service;
@@ -24,4 +25,24 @@ public class TranscriptionBlockQueryService {
}
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.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
import org.springframework.stereotype.Service;
@@ -20,23 +19,23 @@ public class TranscriptionQueueService {
private static final int DEFAULT_QUEUE_SIZE = 5;
private static final int MAX_CONTRIBUTORS = 5;
private final DocumentRepository documentRepository;
private final DocumentService documentService;
private final AuditLogQueryService auditLogQueryService;
public List<TranscriptionQueueItemDTO> getSegmentationQueue() {
return enrichWithContributors(documentRepository.findSegmentationQueue(DEFAULT_QUEUE_SIZE));
return enrichWithContributors(documentService.findSegmentationQueue(DEFAULT_QUEUE_SIZE));
}
public List<TranscriptionQueueItemDTO> getTranscriptionQueue() {
return enrichWithContributors(documentRepository.findTranscriptionQueue(DEFAULT_QUEUE_SIZE));
return enrichWithContributors(documentService.findTranscriptionQueue(DEFAULT_QUEUE_SIZE));
}
public List<TranscriptionQueueItemDTO> getReadyToReadQueue() {
return enrichWithContributors(documentRepository.findReadyToReadQueue(DEFAULT_QUEUE_SIZE));
return enrichWithContributors(documentService.findReadyToReadQueue(DEFAULT_QUEUE_SIZE));
}
public TranscriptionWeeklyStatsDTO getWeeklyStats() {
var stats = documentRepository.findWeeklyStats();
var stats = documentService.findWeeklyStats();
return new TranscriptionWeeklyStatsDTO(
stats.getSegmentationCount(),
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.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
import org.springframework.stereotype.Service;
@@ -37,7 +36,6 @@ public class TranscriptionService {
private final TranscriptionBlockRepository blockRepository;
private final TranscriptionBlockVersionRepository versionRepository;
private final AnnotationRepository annotationRepository;
private final AnnotationService annotationService;
private final DocumentService documentService;
private final SenderModelService senderModelService;
@@ -47,6 +45,11 @@ public class TranscriptionService {
return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
}
@Transactional
public void deleteByAnnotationId(UUID annotationId) {
blockRepository.deleteByAnnotationId(annotationId);
}
public TranscriptionBlock getBlock(UUID documentId, UUID blockId) {
return blockRepository.findByIdAndDocumentId(blockId, documentId)
.orElseThrow(() -> DomainException.notFound(
@@ -142,7 +145,7 @@ public class TranscriptionService {
saveVersion(saved, userId);
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);
auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId,
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)
blockRepository.delete(block);
blockRepository.flush();
annotationRepository.deleteById(annotationId);
annotationService.deleteById(annotationId);
log.info("Deleted transcription block {} and annotation {} for document {}",
blockId, annotationId, documentId);
}
@@ -181,7 +184,7 @@ public class TranscriptionService {
blockRepository.deleteAll(blocks);
blockRepository.flush();
annotationRepository.deleteAllById(annotationIds);
annotationService.deleteAllById(annotationIds);
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));
}
public Optional<AppUser> findByEmailOptional(String email) {
return userRepository.findByEmail(email);
}
public AppUser save(AppUser user) {
return userRepository.save(user);
}
public List<AppUser> getAllUsers() {
return userRepository.findAll();
}

View File

@@ -2,10 +2,10 @@ package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.dto.StatsDTO;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.StatsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
@@ -25,8 +25,7 @@ class StatsControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean PersonRepository personRepository;
@MockitoBean DocumentRepository documentRepository;
@MockitoBean StatsService statsService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
@Test
@@ -37,9 +36,15 @@ class StatsControllerTest {
@Test
@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 {
when(personRepository.count()).thenReturn(4L);
when(documentRepository.count()).thenReturn(12L);
when(statsService.getStats()).thenReturn(new StatsDTO(4L, 12L));
mockMvc.perform(get("/api/stats"))
.andExpect(status().isOk())
@@ -48,10 +53,9 @@ class StatsControllerTest {
}
@Test
@WithMockUser
@WithMockUser(authorities = "READ_ALL")
void getStats_returns200_withZeroCounts() throws Exception {
when(personRepository.count()).thenReturn(0L);
when(documentRepository.count()).thenReturn(0L);
when(statsService.getStats()).thenReturn(new StatsDTO(0L, 0L));
mockMvc.perform(get("/api/stats"))
.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.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.Map;
@@ -36,7 +35,7 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
class AnnotationServiceTest {
@Mock AnnotationRepository annotationRepository;
@Mock TranscriptionBlockRepository blockRepository;
@Mock TranscriptionService transcriptionService;
@Mock AuditService auditService;
@InjectMocks AnnotationService annotationService;
@@ -208,7 +207,7 @@ class AnnotationServiceTest {
annotationService.deleteAnnotation(docId, annotId, ownerId);
verify(blockRepository).deleteByAnnotationId(annotId);
verify(transcriptionService).deleteByAnnotationId(annotId);
verify(annotationRepository).delete(annotation);
}
@@ -225,8 +224,8 @@ class AnnotationServiceTest {
annotationService.deleteAnnotation(docId, annotId, ownerId);
var inOrder = org.mockito.Mockito.inOrder(blockRepository, annotationRepository);
inOrder.verify(blockRepository).deleteByAnnotationId(annotId);
var inOrder = org.mockito.Mockito.inOrder(transcriptionService, annotationRepository);
inOrder.verify(transcriptionService).deleteByAnnotationId(annotId);
inOrder.verify(annotationRepository).delete(annotation);
}

View File

@@ -2264,4 +2264,67 @@ class DocumentServiceTest {
assertThat(doc.getArchiveFolder()).isEqualTo("KeepFolder");
assertThat(doc.getDocumentLocation()).isEqualTo("KeepLocation");
}
// ─── findById (no-throw variant) ───────────────────────────────────────────
@Test
void findById_returnsEmpty_whenDocumentDoesNotExist() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
assertThat(documentService.findById(id)).isEmpty();
}
@Test
void findById_returnsDocument_whenPresent() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).title("T").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
assertThat(documentService.findById(id)).contains(doc);
}
// ─── findForThumbnailBackfill ──────────────────────────────────────────────
@Test
void findForThumbnailBackfill_returnsRepositoryResult() {
Document a = Document.builder().id(UUID.randomUUID()).title("A").build();
Document b = Document.builder().id(UUID.randomUUID()).title("B").build();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
.thenReturn(List.of(a, b));
assertThat(documentService.findForThumbnailBackfill()).containsExactly(a, b);
}
// ─── updateThumbnailMetadata ───────────────────────────────────────────────
@Test
void updateThumbnailMetadata_savesDocument() {
Document doc = Document.builder().id(UUID.randomUUID()).title("T").build();
when(documentRepository.save(doc)).thenReturn(doc);
assertThat(documentService.updateThumbnailMetadata(doc)).isEqualTo(doc);
verify(documentRepository).save(doc);
}
// ─── 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.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
@@ -35,7 +34,7 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class MassImportServiceTest {
@Mock DocumentRepository documentRepository;
@Mock DocumentService documentService;
@Mock PersonService personService;
@Mock TagService tagService;
@Mock S3Client s3Client;
@@ -45,7 +44,7 @@ class MassImportServiceTest {
@BeforeEach
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, "colIndex", 0);
ReflectionTestUtils.setField(service, "colBox", 1);
@@ -96,23 +95,23 @@ class MassImportServiceTest {
.originalFilename("doc001.pdf")
.status(DocumentStatus.UPLOADED)
.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");
verify(documentRepository, never()).save(any());
verify(documentService, never()).save(any());
}
// ─── importSingleDocument — create new document (metadata only) ───────────
@Test
void importSingleDocument_createsNewDocument_whenNotExists() {
when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
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.getStatus() == DocumentStatus.PLACEHOLDER));
}
@@ -126,12 +125,12 @@ class MassImportServiceTest {
.originalFilename("existing.pdf")
.status(DocumentStatus.PLACEHOLDER)
.build();
when(documentRepository.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder));
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder));
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
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) ─────────────────────────
@@ -141,14 +140,14 @@ class MassImportServiceTest {
Path tempFile = tempDir.resolve("doc003.pdf");
Files.write(tempFile, "PDF content".getBytes());
when(documentRepository.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.importSingleDocument(
minimalCells("doc003.pdf"), Optional.of(tempFile.toFile()), "doc003.pdf", "doc003");
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
@@ -156,42 +155,42 @@ class MassImportServiceTest {
Path tempFile = tempDir.resolve("fail.pdf");
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"))
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
service.importSingleDocument(
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
verify(documentRepository, never()).save(any());
verify(documentService, never()).save(any());
}
// ─── importSingleDocument — sender handling ───────────────────────────────
@Test
void importSingleDocument_setsNullSender_whenSenderCellIsBlank() {
when(documentRepository.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("nosender.pdf", "", "", "");
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());
}
@Test
void importSingleDocument_createsSender_whenSenderCellIsNonBlank() {
Person sender = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(documentRepository.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(sender);
List<String> cells = buildCells("withsender.pdf", "Walter Müller", "", "");
service.importSingleDocument(cells, Optional.empty(), "withsender.pdf", "withsender");
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 ─────────────────────────────────
@@ -199,8 +198,8 @@ class MassImportServiceTest {
@Test
void importSingleDocument_createsTag_whenTagCellIsNonBlank() {
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
when(documentRepository.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(tagService.findOrCreate("Familie")).thenReturn(tag);
List<String> cells = buildCells("tagged.pdf", "", "", "Familie");
@@ -211,8 +210,8 @@ class MassImportServiceTest {
@Test
void importSingleDocument_doesNotCreateTag_whenTagCellIsBlank() {
when(documentRepository.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("notag.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "notag.pdf", "notag");
@@ -225,38 +224,38 @@ class MassImportServiceTest {
@Test
void importSingleDocument_metadataComplete_whenSenderPresent() {
Person sender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
when(documentRepository.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("A B")).thenReturn(sender);
List<String> cells = buildCells("meta.pdf", "A B", "", "");
service.importSingleDocument(cells, Optional.empty(), "meta.pdf", "meta");
verify(documentRepository).save(argThat(Document::isMetadataComplete));
verify(documentService).save(argThat(Document::isMetadataComplete));
}
@Test
void importSingleDocument_metadataIncomplete_whenNoKeyFieldsPresent() {
when(documentRepository.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("nometa.pdf", "", "", "");
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 ─────────────────────
@Test
void importSingleDocument_setsBlankFieldsToNull() {
when(documentRepository.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = buildCells("blank.pdf", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "blank.pdf", "blank");
verify(documentRepository).save(argThat(d ->
verify(documentService).save(argThat(d ->
d.getLocation() == null &&
d.getSummary() == null &&
d.getTranscription() == null &&
@@ -281,13 +280,13 @@ class MassImportServiceTest {
);
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(0);
verify(documentRepository, never()).findByOriginalFilename(any());
verify(documentService, never()).findByOriginalFilename(any());
}
@Test
void processRows_addsExtension_whenIndexHasNoDot() {
when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of(
List.of("header"),
@@ -296,13 +295,13 @@ class MassImportServiceTest {
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(1);
verify(documentRepository).findByOriginalFilename("doc001.pdf");
verify(documentService).findByOriginalFilename("doc001.pdf");
}
@Test
void processRows_usesFilenameAsIs_whenIndexHasDot() {
when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<List<String>> rows = List.of(
List.of("header"),
@@ -311,15 +310,15 @@ class MassImportServiceTest {
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
assertThat(result).isEqualTo(1);
verify(documentRepository).findByOriginalFilename("doc002.pdf");
verify(documentService).findByOriginalFilename("doc002.pdf");
}
// ─── importSingleDocument — non-blank optional fields ────────────────────
@Test
void importSingleDocument_setsNonNullOptionalFields_whenPresent() {
when(documentRepository.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
// box=1, folder=2, location=9, summary=11, transcription=13
List<String> cells = List.of(
@@ -341,7 +340,7 @@ class MassImportServiceTest {
service.importSingleDocument(cells, Optional.empty(), "rich.pdf", "rich");
verify(documentRepository).save(argThat(d ->
verify(documentService).save(argThat(d ->
"Box A".equals(d.getArchiveBox()) &&
"Folder B".equals(d.getArchiveFolder()) &&
"Hamburg".equals(d.getLocation()) &&
@@ -352,27 +351,27 @@ class MassImportServiceTest {
@Test
void importSingleDocument_setsMetadataComplete_whenReceiversArePresent() {
Person receiver = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
when(documentRepository.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(receiver);
List<String> cells = List.of(
"rcv.pdf", "", "", "", "", "Walter Müller", "", "", "", "", "", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "rcv.pdf", "rcv");
verify(documentRepository).save(argThat(Document::isMetadataComplete));
verify(documentService).save(argThat(Document::isMetadataComplete));
}
@Test
void importSingleDocument_setsMetadataComplete_whenDateIsPresent() {
when(documentRepository.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty());
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty());
when(documentService.save(any())).thenAnswer(inv -> inv.getArgument(0));
List<String> cells = List.of(
"dated.pdf", "", "", "", "", "", "", "2024-03-15", "", "", "", "", "", "");
service.importSingleDocument(cells, Optional.empty(), "dated.pdf", "dated");
verify(documentRepository).save(argThat(Document::isMetadataComplete));
verify(documentService).save(argThat(Document::isMetadataComplete));
}
// ─── 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.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.OcrTrainingRunRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
@@ -34,7 +33,7 @@ class OcrTrainingServiceTest {
SegmentationTrainingExportService segExportService;
OcrClient ocrClient;
OcrHealthClient healthClient;
TranscriptionBlockRepository blockRepository;
TranscriptionBlockQueryService transcriptionBlockQueryService;
TransactionTemplate txTemplate;
PersonService personService;
SenderModelService senderModelService;
@@ -47,7 +46,7 @@ class OcrTrainingServiceTest {
segExportService = mock(SegmentationTrainingExportService.class);
ocrClient = mock(OcrClient.class);
healthClient = mock(OcrHealthClient.class);
blockRepository = mock(TranscriptionBlockRepository.class);
transcriptionBlockQueryService = mock(TranscriptionBlockQueryService.class);
txTemplate = mock(TransactionTemplate.class);
personService = mock(PersonService.class);
senderModelService = mock(SenderModelService.class);
@@ -58,9 +57,9 @@ class OcrTrainingServiceTest {
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(segExportService.querySegmentationBlocks()).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.model.AppUser;
import org.raddatz.familienarchiv.model.PasswordResetToken;
import org.raddatz.familienarchiv.repository.AppUserRepository;
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
import org.springframework.mail.MailSendException;
import org.springframework.mail.SimpleMailMessage;
@@ -33,7 +32,7 @@ import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class PasswordResetServiceTest {
@Mock AppUserRepository userRepository;
@Mock UserService userService;
@Mock PasswordResetTokenRepository tokenRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock JavaMailSender mailSender;
@@ -53,7 +52,7 @@ class PasswordResetServiceTest {
@Test
void requestReset_savesTokenForKnownEmail() {
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");
@@ -65,7 +64,7 @@ class PasswordResetServiceTest {
@Test
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");
@@ -93,7 +92,7 @@ class PasswordResetServiceTest {
service.resetPassword(req);
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();
}
@@ -153,7 +152,7 @@ class PasswordResetServiceTest {
void requestReset_skipsEmail_whenMailSenderIsNull() {
ReflectionTestUtils.setField(service, "mailSender", null);
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
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
ReflectionTestUtils.setField(service, "mailSender", mailSender);
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));
// 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.repository.OcrTrainingRunRepository;
import org.raddatz.familienarchiv.repository.SenderModelRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionCallback;
import org.springframework.transaction.support.TransactionTemplate;
@@ -28,7 +27,7 @@ import static org.mockito.Mockito.*;
class SenderModelServiceTest {
SenderModelRepository senderModelRepository;
TranscriptionBlockRepository blockRepository;
TranscriptionBlockQueryService transcriptionBlockQueryService;
OcrTrainingRunRepository trainingRunRepository;
OcrClient ocrClient;
TransactionTemplate txTemplate;
@@ -42,7 +41,7 @@ class SenderModelServiceTest {
@BeforeEach
void setUp() {
senderModelRepository = mock(SenderModelRepository.class);
blockRepository = mock(TranscriptionBlockRepository.class);
transcriptionBlockQueryService = mock(TranscriptionBlockQueryService.class);
trainingRunRepository = mock(OcrTrainingRunRepository.class);
ocrClient = mock(OcrClient.class);
txTemplate = mock(TransactionTemplate.class);
@@ -57,7 +56,7 @@ class SenderModelServiceTest {
return callback.doInTransaction(null);
});
service = new SenderModelService(senderModelRepository, blockRepository,
service = new SenderModelService(senderModelRepository, transcriptionBlockQueryService,
trainingRunRepository, ocrClient, txTemplate, trainingDataExportService, personService);
ReflectionTestUtils.setField(service, "self", selfProxy);
ReflectionTestUtils.setField(service, "activationThreshold", 100);
@@ -82,7 +81,7 @@ class SenderModelServiceTest {
@Test
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
when(trainingRunRepository.findFirstByPersonIdAndStatus(personId, TrainingStatus.RUNNING))
.thenReturn(Optional.empty());
@@ -93,14 +92,14 @@ class SenderModelServiceTest {
// triggerSenderTraining will throw when no RUNNING row found
}
verify(blockRepository).countManualKurrentBlocksByPerson(personId);
verify(transcriptionBlockQueryService).countManualKurrentBlocksByPerson(personId);
}
// ─── Activation threshold ─────────────────────────────────────────────────
@Test
void checkAndTriggerTraining_doesNothing_belowActivationThreshold() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(99L);
when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(99L);
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty());
SenderModelService spy = spy(service);
@@ -111,7 +110,7 @@ class SenderModelServiceTest {
@Test
void checkAndTriggerTraining_triggersTraining_atActivationThreshold() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty());
SenderModelService spy = spy(service);
@@ -129,7 +128,7 @@ class SenderModelServiceTest {
SenderModel existing = SenderModel.builder().personId(personId)
.correctedLinesAtTraining(100).build();
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.of(existing));
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(149L);
when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(149L);
SenderModelService spy = spy(service);
spy.checkAndTriggerTraining(personId);
@@ -142,7 +141,7 @@ class SenderModelServiceTest {
SenderModel existing = SenderModel.builder().personId(personId)
.correctedLinesAtTraining(100).build();
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.of(existing));
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(150L);
when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(150L);
SenderModelService spy = spy(service);
doReturn(false).when(spy).runOrQueueSenderTraining(personId, 150);
@@ -156,7 +155,7 @@ class SenderModelServiceTest {
@Test
void checkAndTriggerTraining_callsTrigger_whenRunNow() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty());
SenderModelService spy = spy(service);
@@ -170,7 +169,7 @@ class SenderModelServiceTest {
@Test
void checkAndTriggerTraining_doesNotCallTrigger_whenQueued() {
when(blockRepository.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(personId)).thenReturn(100L);
when(senderModelRepository.findByPersonId(personId)).thenReturn(Optional.empty());
SenderModelService spy = spy(service);
@@ -200,7 +199,7 @@ class SenderModelServiceTest {
when(trainingRunRepository.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(
Optional.of(OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.RUNNING)
.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));
boolean result = service.runOrQueueSenderTraining(personId, 120);
@@ -226,7 +225,7 @@ class SenderModelServiceTest {
// eliminating the race window between the check and a separate triggerSenderTraining call.
when(trainingRunRepository.existsByPersonIdAndStatus(personId, TrainingStatus.QUEUED)).thenReturn(false);
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 -> {
OcrTrainingRun r = inv.getArgument(0);
if (r.getId() == null) r.setId(UUID.randomUUID());
@@ -314,7 +313,7 @@ class SenderModelServiceTest {
@Test
void triggerManualSenderTraining_returnsRunningRun_whenIdle() {
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.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty());
OcrTrainingRun runningRun = OcrTrainingRun.builder()
@@ -333,7 +332,7 @@ class SenderModelServiceTest {
@Test
void triggerManualSenderTraining_returnsQueuedRun_whenAnotherRunning() {
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.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(
Optional.of(OcrTrainingRun.builder().id(UUID.randomUUID()).status(TrainingStatus.RUNNING)
@@ -363,7 +362,7 @@ class SenderModelServiceTest {
@Test
void triggerManualSenderTraining_throwsDomainException_whenRunRowMissingAfterCreate() {
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.findFirstByStatus(TrainingStatus.RUNNING)).thenReturn(Optional.empty());
OcrTrainingRun runningRun = OcrTrainingRun.builder()
@@ -405,7 +404,7 @@ class SenderModelServiceTest {
.modelName("sender_" + nextPersonId).build();
when(trainingRunRepository.findFirstByStatusOrderByCreatedAtAsc(TrainingStatus.QUEUED))
.thenReturn(Optional.of(queued));
when(blockRepository.countManualKurrentBlocksByPerson(nextPersonId)).thenReturn(5L);
when(transcriptionBlockQueryService.countManualKurrentBlocksByPerson(nextPersonId)).thenReturn(5L);
SenderModelService spy = spy(service);
// 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.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
@@ -18,22 +17,22 @@ import static org.mockito.Mockito.*;
class ThumbnailAsyncRunnerTest {
private DocumentRepository documentRepository;
private DocumentService documentService;
private ThumbnailService thumbnailService;
private ThumbnailAsyncRunner runner;
@BeforeEach
void setUp() {
documentRepository = mock(DocumentRepository.class);
documentService = mock(DocumentService.class);
thumbnailService = mock(ThumbnailService.class);
runner = new ThumbnailAsyncRunner(documentRepository, thumbnailService);
runner = new ThumbnailAsyncRunner(documentService, thumbnailService);
}
@Test
void dispatchAfterCommit_whenNoTransaction_dispatchesImmediately() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentService.findById(id)).thenReturn(Optional.of(doc));
runner.dispatchAfterCommit(id);
@@ -44,7 +43,7 @@ class ThumbnailAsyncRunnerTest {
void dispatchAfterCommit_whenTransactionActive_registersAfterCommitSynchronization() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentService.findById(id)).thenReturn(Optional.of(doc));
TransactionSynchronizationManager.initSynchronization();
try {
@@ -69,7 +68,7 @@ class ThumbnailAsyncRunnerTest {
void dispatchAfterCommit_whenRollback_doesNotDispatch() {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentService.findById(id)).thenReturn(Optional.of(doc));
TransactionSynchronizationManager.initSynchronization();
try {
@@ -88,7 +87,7 @@ class ThumbnailAsyncRunnerTest {
@Test
void generateAsync_skipsWhenDocumentMissing() {
UUID id = UUID.randomUUID();
when(documentRepository.findById(id)).thenReturn(Optional.empty());
when(documentService.findById(id)).thenReturn(Optional.empty());
runner.generateAsync(id);
@@ -99,7 +98,7 @@ class ThumbnailAsyncRunnerTest {
void generateAsync_timesOutWhenGenerateExceedsLimit() throws Exception {
UUID id = UUID.randomUUID();
Document doc = Document.builder().id(id).originalFilename("f.pdf").title("t").build();
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
when(documentService.findById(id)).thenReturn(Optional.of(doc));
// generate sleeps longer than the timeout — simulates a hung PDFBox render
when(thumbnailService.generate(doc)).thenAnswer(inv -> {
Thread.sleep(5_000);

View File

@@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
@@ -19,15 +18,15 @@ import static org.mockito.Mockito.*;
class ThumbnailBackfillServiceTest {
private DocumentRepository documentRepository;
private DocumentService documentService;
private ThumbnailService thumbnailService;
private ThumbnailBackfillService backfillService;
@BeforeEach
void setUp() {
documentRepository = mock(DocumentRepository.class);
documentService = mock(DocumentService.class);
thumbnailService = mock(ThumbnailService.class);
backfillService = new ThumbnailBackfillService(documentRepository, thumbnailService);
backfillService = new ThumbnailBackfillService(documentService, thumbnailService);
}
@Test
@@ -45,7 +44,7 @@ class ThumbnailBackfillServiceTest {
Document a = doc();
Document b = doc();
Document c = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(a, b, c));
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);
@@ -64,7 +63,7 @@ class ThumbnailBackfillServiceTest {
void runBackfillAsync_countsSkippedSeparately() {
Document a = doc();
Document b = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(a, b));
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SKIPPED);
@@ -83,7 +82,7 @@ class ThumbnailBackfillServiceTest {
Document a = doc();
Document b = doc();
Document c = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(a, b, c));
when(thumbnailService.generate(a)).thenReturn(ThumbnailService.Outcome.SUCCESS);
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.FAILED);
@@ -102,7 +101,7 @@ class ThumbnailBackfillServiceTest {
void runBackfillAsync_continuesWhenServiceThrowsUnexpectedException() {
Document a = doc();
Document b = doc();
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(a, b));
when(thumbnailService.generate(a)).thenThrow(new RuntimeException("boom"));
when(thumbnailService.generate(b)).thenReturn(ThumbnailService.Outcome.SUCCESS);
@@ -130,7 +129,7 @@ class ThumbnailBackfillServiceTest {
@Test
void runBackfillAsync_setsStartedAtAndMessage() {
when(documentRepository.findByFilePathIsNotNullAndThumbnailKeyIsNull())
when(documentService.findForThumbnailBackfill())
.thenReturn(List.of(doc()));
when(thumbnailService.generate(any())).thenReturn(ThumbnailService.Outcome.SUCCESS);

View File

@@ -12,7 +12,6 @@ import org.mockito.ArgumentCaptor;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.ThumbnailAspect;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.springframework.test.util.ReflectionTestUtils;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
@@ -39,17 +38,17 @@ class ThumbnailServiceTest {
private FileService fileService;
private S3Client s3Client;
private DocumentRepository documentRepository;
private DocumentService documentService;
private ThumbnailService thumbnailService;
@BeforeEach
void setUp() {
fileService = mock(FileService.class);
s3Client = mock(S3Client.class);
documentRepository = mock(DocumentRepository.class);
thumbnailService = new ThumbnailService(fileService, s3Client, documentRepository);
documentService = mock(DocumentService.class);
thumbnailService = new ThumbnailService(fileService, s3Client, documentService);
ReflectionTestUtils.setField(thumbnailService, "bucketName", "test-bucket");
when(documentRepository.save(any(Document.class))).thenAnswer(i -> i.getArgument(0));
when(documentService.updateThumbnailMetadata(any(Document.class))).thenAnswer(i -> i.getArgument(0));
}
@Test
@@ -103,7 +102,7 @@ class ThumbnailServiceTest {
assertThat(doc.getThumbnailKey()).isEqualTo("thumbnails/" + doc.getId() + ".jpg");
assertThat(doc.getThumbnailGeneratedAt()).isNotNull();
verify(documentRepository).save(doc);
verify(documentService).updateThumbnailMetadata(doc);
}
@Test
@@ -152,7 +151,7 @@ class ThumbnailServiceTest {
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
assertThat(doc.getThumbnailKey()).isNull();
verify(documentRepository, never()).save(any());
verify(documentService, never()).updateThumbnailMetadata(any());
}
@Test
@@ -165,7 +164,7 @@ class ThumbnailServiceTest {
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any());
verify(documentService, never()).updateThumbnailMetadata(any());
}
@Test
@@ -260,7 +259,7 @@ class ThumbnailServiceTest {
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any());
verify(documentService, never()).updateThumbnailMetadata(any());
}
@Test
@@ -275,7 +274,7 @@ class ThumbnailServiceTest {
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verifyNoInteractions(s3Client);
verify(documentRepository, never()).save(any());
verify(documentService, never()).updateThumbnailMetadata(any());
}
@Test
@@ -286,14 +285,14 @@ class ThumbnailServiceTest {
Document doc = makeDoc("application/pdf", "documents/letter.pdf");
when(fileService.downloadFileStream(anyString()))
.thenReturn(new ByteArrayInputStream(createSamplePdf()));
when(documentRepository.save(any()))
when(documentService.updateThumbnailMetadata(any()))
.thenThrow(new RuntimeException("constraint violation"));
ThumbnailService.Outcome outcome = thumbnailService.generate(doc);
assertThat(outcome).isEqualTo(ThumbnailService.Outcome.FAILED);
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
verify(documentRepository).save(any());
verify(documentService).updateThumbnailMetadata(any());
}
// ─── helpers ──────────────────────────────────────────────────────────────

View File

@@ -27,6 +27,7 @@ import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@@ -60,7 +61,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, "Liebe Mutter"));
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip();
byte[] zipBytes = stream(body);
@@ -79,7 +80,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(block);
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip();
assertThat(zipEntryNames(stream(body))).isEmpty();
@@ -92,7 +93,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, "Liebe Tante"));
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip();
byte[] zipBytes = stream(body);
@@ -110,7 +111,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(block);
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip();
assertThat(zipEntryNames(stream(body))).isNotEmpty();
@@ -127,7 +128,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(block);
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
StreamingResponseBody body = service.exportToZip();
assertThat(zipEntryNames(stream(body))).isEmpty();
@@ -143,7 +144,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, "Zweite Zeile"));
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
byte[] zipBytes = stream(service.exportToZip());
var names = zipEntryNames(zipBytes);
@@ -160,7 +161,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, expectedText));
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
byte[] zipBytes = stream(service.exportToZip());
String xmlContent = readZipEntry(zipBytes, ".xml");
@@ -174,7 +175,7 @@ class TrainingDataExportServiceTest {
blockRepository.save(manualBlock(docId, annotId, "A & B < C > D"));
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
byte[] zipBytes = stream(service.exportToZip());
String xmlContent = readZipEntry(zipBytes, ".xml");
@@ -196,7 +197,7 @@ class TrainingDataExportServiceTest {
when(fileService.downloadFileBytes("fail.pdf")).thenThrow(new FileService.StorageFileNotFoundException("missing"));
when(fileService.downloadFileBytes("ok.pdf")).thenReturn(minimalPdfBytes);
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
byte[] zipBytes = stream(service.exportToZip());
var names = zipEntryNames(zipBytes);
@@ -209,13 +210,33 @@ class TrainingDataExportServiceTest {
@Test
void queryEligibleBlocks_returnsEmpty_whenNoEnrolledDocuments() {
FileService fileService = mockFileService();
TrainingDataExportService service = new TrainingDataExportService(blockRepository, annotationRepository, documentRepository, fileService);
TrainingDataExportService service = makeService(fileService);
assertThat(service.queryEligibleBlocks()).isEmpty();
}
// ─── 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) {
Document doc = documentRepository.save(Document.builder()
.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.dto.TranscriptionQueueItemDTO;
import org.raddatz.familienarchiv.dto.TranscriptionWeeklyStatsDTO;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TranscriptionQueueProjection;
import org.raddatz.familienarchiv.repository.TranscriptionWeeklyStatsProjection;
@@ -26,7 +25,7 @@ import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TranscriptionQueueServiceTest {
@Mock DocumentRepository documentRepository;
@Mock DocumentService documentService;
@Mock AuditLogQueryService auditLogQueryService;
@InjectMocks TranscriptionQueueService service;
@@ -41,11 +40,11 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_delegatesToRepositoryWithDefaultSize() {
UUID id = UUID.randomUUID();
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();
verify(documentRepository).findSegmentationQueue(5);
verify(documentService).findSegmentationQueue(5);
assertThat(result).hasSize(1);
assertThat(result.get(0).id()).isEqualTo(id);
assertThat(result.get(0).title()).isEqualTo("Brief von 1920");
@@ -55,7 +54,7 @@ class TranscriptionQueueServiceTest {
@Test
void getSegmentationQueue_returnsEmptyList_whenQueueIsEmpty() {
when(documentRepository.findSegmentationQueue(5)).thenReturn(List.of());
when(documentService.findSegmentationQueue(5)).thenReturn(List.of());
List<TranscriptionQueueItemDTO> result = service.getSegmentationQueue();
@@ -67,7 +66,7 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_returnsAllFive_andHasMoreFalse_whenExactlyFiveContributors() {
UUID docId = UUID.randomUUID();
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(
new ActivityActorDTO("A1", "#111", "Alice One"),
@@ -89,7 +88,7 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_mapsDocumentDateWhenPresent() {
LocalDate date = LocalDate.of(1920, 6, 15);
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();
@@ -102,11 +101,11 @@ class TranscriptionQueueServiceTest {
void getTranscriptionQueue_delegatesToRepositoryWithDefaultSize() {
UUID id = UUID.randomUUID();
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();
verify(documentRepository).findTranscriptionQueue(5);
verify(documentService).findTranscriptionQueue(5);
assertThat(result).hasSize(1);
assertThat(result.get(0).annotationCount()).isEqualTo(3);
assertThat(result.get(0).textedBlockCount()).isEqualTo(1);
@@ -118,11 +117,11 @@ class TranscriptionQueueServiceTest {
@Test
void getReadyToReadQueue_delegatesToRepositoryWithDefaultSize() {
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();
verify(documentRepository).findReadyToReadQueue(5);
verify(documentService).findReadyToReadQueue(5);
assertThat(result).hasSize(1);
assertThat(result.get(0).reviewedBlockCount()).isEqualTo(4);
}
@@ -132,7 +131,7 @@ class TranscriptionQueueServiceTest {
@Test
void getWeeklyStats_mapsProjectionToDTO() {
TranscriptionWeeklyStatsProjection proj = mockStatsProjection(3L, 7L);
when(documentRepository.findWeeklyStats()).thenReturn(proj);
when(documentService.findWeeklyStats()).thenReturn(proj);
TranscriptionWeeklyStatsDTO result = service.getWeeklyStats();
@@ -143,7 +142,7 @@ class TranscriptionQueueServiceTest {
@Test
void getWeeklyStats_returnsZeros_whenAllCountsAreZero() {
TranscriptionWeeklyStatsProjection proj = mockStatsProjection(0L, 0L);
when(documentRepository.findWeeklyStats()).thenReturn(proj);
when(documentService.findWeeklyStats()).thenReturn(proj);
TranscriptionWeeklyStatsDTO result = service.getWeeklyStats();
@@ -157,7 +156,7 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_includesContributors_whenAuditDataPresent() {
UUID docId = UUID.randomUUID();
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");
when(auditLogQueryService.findContributorsPerDocument(List.of(docId)))
@@ -173,7 +172,7 @@ class TranscriptionQueueServiceTest {
void getSegmentationQueue_capsContributorsAtFive_andSetsHasMoreFlag() {
UUID docId = UUID.randomUUID();
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(
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.model.BlockSource;
import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
@@ -20,7 +19,6 @@ class TranscriptionServiceGuidedTest {
TranscriptionBlockRepository blockRepository;
TranscriptionBlockVersionRepository versionRepository;
AnnotationRepository annotationRepository;
AnnotationService annotationService;
DocumentService documentService;
SenderModelService senderModelService;
@@ -35,14 +33,13 @@ class TranscriptionServiceGuidedTest {
void setUp() {
blockRepository = mock(TranscriptionBlockRepository.class);
versionRepository = mock(TranscriptionBlockVersionRepository.class);
annotationRepository = mock(AnnotationRepository.class);
annotationService = mock(AnnotationService.class);
documentService = mock(DocumentService.class);
senderModelService = mock(SenderModelService.class);
auditService = mock(AuditService.class);
service = new TranscriptionService(blockRepository, versionRepository,
annotationRepository, annotationService, documentService, senderModelService, auditService);
annotationService, documentService, senderModelService, auditService);
when(blockRepository.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.TranscriptionBlock;
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
@@ -44,7 +43,6 @@ class TranscriptionServiceTest {
@Mock TranscriptionBlockRepository blockRepository;
@Mock TranscriptionBlockVersionRepository versionRepository;
@Mock AnnotationRepository annotationRepository;
@Mock AnnotationService annotationService;
@Mock DocumentService documentService;
@Mock SenderModelService senderModelService;
@@ -320,7 +318,7 @@ class TranscriptionServiceTest {
verify(blockRepository).delete(block);
verify(blockRepository).flush();
verify(annotationRepository).deleteById(annotId);
verify(annotationService).deleteById(annotId);
}
@Test
@@ -354,7 +352,7 @@ class TranscriptionServiceTest {
verify(blockRepository).deleteAll(List.of(block1, block2));
verify(blockRepository).flush();
verify(annotationRepository).deleteAllById(List.of(annId1, annId2));
verify(annotationService).deleteAllById(List.of(annId1, annId2));
}
@Test
@@ -532,7 +530,7 @@ class TranscriptionServiceTest {
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
when(documentService.getDocumentById(any())).thenReturn(
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);

View File

@@ -58,6 +58,34 @@ class UserServiceTest {
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 ───────────────────────────────────────────────────────────
@Test