From efc13d841e2097baef743cfc23dcbd6111439f27 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Mon, 6 Apr 2026 14:37:47 +0200 Subject: [PATCH] =?UTF-8?q?M4=20Nachbearbeitung=20Anwendungskern=20testsei?= =?UTF-8?q?tig=20gesch=C3=A4rft?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DocumentProcessingCoordinatorTest.java | 387 ++++++++++++++++++ .../BatchRunProcessingUseCaseTest.java | 243 +++++++++++ 2 files changed, 630 insertions(+) diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java index 4c98ab2..4fa732b 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java @@ -356,6 +356,365 @@ class DocumentProcessingCoordinatorTest { "Attempt number must be taken from the repository"); } + // ------------------------------------------------------------------------- + // Rückgabewert von process() und processDeferredOutcome() + // ------------------------------------------------------------------------- + + @Test + void process_newDocument_preCheckPassed_returnsTrue() { + // Prüft, dass process() bei erfolgreichem Persistieren true zurückgibt + recordRepo.setLookupResult(new DocumentUnknown()); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(result, "process() muss bei erfolgreichem Persistieren true zurückgeben"); + } + + @Test + void process_newDocument_persistenceFailure_returnsFalse() { + // Prüft, dass process() bei Persistenzfehler false zurückgibt (nicht true) + recordRepo.setLookupResult(new DocumentUnknown()); + unitOfWorkPort.failOnExecute = true; + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart); + + assertFalse(result, "process() muss bei Persistenzfehler false zurückgeben"); + } + + @Test + void process_knownDocument_persistenceFailure_returnsFalse() { + // Prüft, dass process() bei Persistenzfehler im known-Pfad false zurückgibt + DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(0, 1)); + recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); + unitOfWorkPort.failOnExecute = true; + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart); + + assertFalse(result, "process() muss bei Persistenzfehler im known-Pfad false zurückgeben"); + } + + @Test + void process_knownDocument_preCheckPassed_returnsTrue() { + // Prüft, dass process() bei erfolgreichem Persistieren im known-Pfad true zurückgibt + DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(0, 1)); + recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(result, "process() muss bei erfolgreichem Persistieren im known-Pfad true zurückgeben"); + } + + @Test + void process_terminalSuccess_skipPersistenceSuccess_returnsTrue() { + // Prüft, dass process() beim Skip-Pfad (SUCCESS) true zurückgibt + DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); + recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(result, "process() muss beim erfolgreichen Skip-Persistieren true zurückgeben"); + } + + @Test + void process_terminalFinalFailure_skipPersistenceSuccess_returnsTrue() { + // Prüft, dass process() beim Skip-Pfad (FAILED_FINAL) true zurückgibt + DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0)); + recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckFailed( + candidate, PreCheckFailureReason.NO_USABLE_TEXT); + + boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(result, "process() muss beim erfolgreichen Skip-Persistieren true zurückgeben"); + } + + @Test + void process_terminalSuccess_skipPersistenceFailure_returnsFalse() { + // Prüft, dass process() false zurückgibt, wenn das Skip-Persistieren fehlschlägt + DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); + recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); + unitOfWorkPort.failOnExecute = true; + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart); + + assertFalse(result, "process() muss false zurückgeben, wenn Skip-Persistierung fehlschlägt"); + } + + @Test + void process_persistenceLookupFailure_returnsFalse() { + // Prüft, dass process() bei Lookup-Fehler false zurückgibt + recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("DB nicht verfügbar", null)); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + boolean result = processor.process(candidate, fingerprint, outcome, context, attemptStart); + + assertFalse(result, "process() muss bei Lookup-Fehler false zurückgeben"); + } + + // ------------------------------------------------------------------------- + // Fehlermeldungsinhalt (buildFailureMessage) + // ------------------------------------------------------------------------- + + @Test + void process_newDocument_firstContentError_failureMessageContainsContentErrorCount() { + // Prüft, dass die Fehlermeldung die Fehleranzahl enthält (nicht leer ist) + recordRepo.setLookupResult(new DocumentUnknown()); + DocumentProcessingOutcome outcome = new PreCheckFailed( + candidate, PreCheckFailureReason.NO_USABLE_TEXT); + + processor.process(candidate, fingerprint, outcome, context, attemptStart); + + ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); + assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_RETRYABLE"); + assertFalse(attempt.failureMessage().isBlank(), + "Fehlermeldung darf nicht leer sein bei FAILED_RETRYABLE"); + assertTrue(attempt.failureMessage().contains("ContentErrors=1"), + "Fehlermeldung muss den Inhaltsfehler-Zähler enthalten: " + attempt.failureMessage()); + } + + @Test + void process_knownDocument_secondContentError_failureMessageContainsFinalStatus() { + // Prüft, dass die Fehlermeldung bei FAILED_FINAL den Endzustand enthält + DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0)); + recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckFailed( + candidate, PreCheckFailureReason.PAGE_LIMIT_EXCEEDED); + + processor.process(candidate, fingerprint, outcome, context, attemptStart); + + ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); + assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_FINAL"); + assertFalse(attempt.failureMessage().isBlank(), + "Fehlermeldung darf nicht leer sein bei FAILED_FINAL"); + assertTrue(attempt.failureMessage().contains("ContentErrors=2"), + "Fehlermeldung muss den aktualisierten Inhaltsfehler-Zähler enthalten: " + attempt.failureMessage()); + } + + @Test + void process_newDocument_technicalError_failureMessageContainsTransientErrorCount() { + // Prüft, dass die Fehlermeldung bei transientem Fehler den Transient-Zähler enthält + recordRepo.setLookupResult(new DocumentUnknown()); + DocumentProcessingOutcome outcome = new TechnicalDocumentError(candidate, "Timeout", null); + + processor.process(candidate, fingerprint, outcome, context, attemptStart); + + ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); + assertNotNull(attempt.failureMessage()); + assertTrue(attempt.failureMessage().contains("TransientErrors=1"), + "Fehlermeldung muss den Transient-Fehler-Zähler enthalten: " + attempt.failureMessage()); + } + + @Test + void process_newDocument_preCheckPassed_failureClassAndMessageAreNull() { + // Prüft, dass bei Erfolg failureClass und failureMessage null sind + recordRepo.setLookupResult(new DocumentUnknown()); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + processor.process(candidate, fingerprint, outcome, context, attemptStart); + + ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); + assertNull(attempt.failureClass(), "Bei Erfolg muss failureClass null sein"); + assertNull(attempt.failureMessage(), "Bei Erfolg muss failureMessage null sein"); + } + + // ------------------------------------------------------------------------- + // Zeitstempel-Korrektheit in buildUpdatedDocumentRecord (negierte Bedingung) + // ------------------------------------------------------------------------- + + @Test + void process_knownDocument_preCheckPassed_lastSuccessInstantSetAndLastFailureInstantFromPreviousRecord() { + // Prüft, dass bei SUCCESS am known-Dokument lastSuccessInstant gesetzt + // und lastFailureInstant aus dem Vorgänger-Datensatz übernommen wird + Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z"); + DocumentRecord existingRecord = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/tmp/test.pdf"), + "test.pdf", + ProcessingStatus.FAILED_RETRYABLE, + new FailureCounters(0, 1), + previousFailureInstant, // lastFailureInstant vorhanden + null, // noch kein Erfolgszeitpunkt + Instant.now(), + Instant.now() + ); + recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + processor.process(candidate, fingerprint, outcome, context, attemptStart); + + DocumentRecord updated = recordRepo.updatedRecords.get(0); + assertNotNull(updated.lastSuccessInstant(), + "lastSuccessInstant muss nach erfolgreichem Verarbeiten gesetzt sein"); + assertEquals(previousFailureInstant, updated.lastFailureInstant(), + "lastFailureInstant muss bei SUCCESS den Vorgänger-Wert beibehalten"); + } + + @Test + void process_knownDocument_contentError_lastFailureInstantSetAndLastSuccessInstantFromPreviousRecord() { + // Prüft, dass bei FAILURE am known-Dokument lastFailureInstant gesetzt + // und lastSuccessInstant aus dem Vorgänger-Datensatz übernommen wird (negierte Bedingung abdecken) + Instant previousSuccessInstant = Instant.parse("2025-01-10T08:00:00Z"); + DocumentRecord existingRecord = new DocumentRecord( + fingerprint, + new SourceDocumentLocator("/tmp/test.pdf"), + "test.pdf", + ProcessingStatus.FAILED_RETRYABLE, + new FailureCounters(0, 0), + null, // noch keine Fehlzeit + previousSuccessInstant, // vorheriger Erfolg vorhanden + Instant.now(), + Instant.now() + ); + recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckFailed( + candidate, PreCheckFailureReason.NO_USABLE_TEXT); + + processor.process(candidate, fingerprint, outcome, context, attemptStart); + + DocumentRecord updated = recordRepo.updatedRecords.get(0); + assertNotNull(updated.lastFailureInstant(), + "lastFailureInstant muss nach Inhaltsfehler gesetzt sein"); + assertEquals(previousSuccessInstant, updated.lastSuccessInstant(), + "lastSuccessInstant muss bei Fehler den Vorgänger-Wert beibehalten"); + } + + // ------------------------------------------------------------------------- + // Logger-Delegation (VoidMethodCallMutator abdecken mit CapturingLogger) + // ------------------------------------------------------------------------- + + @Test + void process_persistenceLookupFailure_logsErrorMessage() { + // Prüft, dass bei Lookup-Fehler ein Fehler-Log-Eintrag erzeugt wird + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + DocumentProcessingCoordinator coordinatorWithCapturingLogger = + new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); + recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null)); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(capturingLogger.errorCallCount > 0, + "Bei Lookup-Fehler muss ein Fehler geloggt werden"); + } + + @Test + void process_terminalSuccess_skipPath_logsInfoMessage() { + // Prüft, dass beim Überspringen eines bereits erfolgreich verarbeiteten Dokuments geloggt wird + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + DocumentProcessingCoordinator coordinatorWithCapturingLogger = + new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); + DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); + recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(capturingLogger.infoCallCount > 0, + "Beim Überspringen eines erfolgreich verarbeiteten Dokuments muss eine Info geloggt werden"); + } + + @Test + void process_terminalFinalFailure_skipPath_logsInfoMessage() { + // Prüft, dass beim Überspringen eines final fehlgeschlagenen Dokuments geloggt wird + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + DocumentProcessingCoordinator coordinatorWithCapturingLogger = + new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); + DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0)); + recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckFailed( + candidate, PreCheckFailureReason.NO_USABLE_TEXT); + + coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(capturingLogger.infoCallCount > 0, + "Beim Überspringen eines final fehlgeschlagenen Dokuments muss eine Info geloggt werden"); + } + + @Test + void process_newDocument_preCheckPassed_logsInfo() { + // Prüft, dass nach erfolgreichem Persistieren einer neuen Datei geloggt wird + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + DocumentProcessingCoordinator coordinatorWithCapturingLogger = + new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); + recordRepo.setLookupResult(new DocumentUnknown()); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(capturingLogger.infoCallCount > 0, + "Nach erfolgreichem Verarbeiten eines neuen Dokuments muss eine Info geloggt werden"); + } + + @Test + void process_newDocument_persistenceFailure_logsError() { + // Prüft, dass bei Persistenzfehler ein Fehler-Log-Eintrag erzeugt wird + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + DocumentProcessingCoordinator coordinatorWithCapturingLogger = + new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); + recordRepo.setLookupResult(new DocumentUnknown()); + unitOfWorkPort.failOnExecute = true; + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(capturingLogger.errorCallCount > 0, + "Bei Persistenzfehler muss ein Fehler geloggt werden"); + } + + @Test + void process_terminalSuccess_successfulSkip_logsDebug() { + // Prüft, dass nach erfolgreichem Skip-Persistieren ein Debug-Log erzeugt wird (persistSkipAttempt L301) + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + DocumentProcessingCoordinator coordinatorWithCapturingLogger = + new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); + DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); + recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(capturingLogger.debugCallCount > 0, + "Nach erfolgreichem Skip-Persistieren muss ein Debug-Eintrag geloggt werden"); + } + + @Test + void process_terminalSuccess_skipPersistenceFailure_logsError() { + // Prüft, dass bei Persistenzfehler im Skip-Pfad ein Fehler geloggt wird (persistSkipAttempt L306) + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + DocumentProcessingCoordinator coordinatorWithCapturingLogger = + new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger); + DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero()); + recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord)); + unitOfWorkPort.failOnExecute = true; + DocumentProcessingOutcome outcome = new PreCheckPassed( + candidate, new PdfExtractionSuccess("text", new PdfPageCount(1))); + + coordinatorWithCapturingLogger.process(candidate, fingerprint, outcome, context, attemptStart); + + assertTrue(capturingLogger.errorCallCount > 0, + "Bei Persistenzfehler im Skip-Pfad muss ein Fehler geloggt werden"); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -490,4 +849,32 @@ class DocumentProcessingCoordinatorTest { // No-op } } + + /** Zählt Logger-Aufrufe je Level, um VoidMethodCallMutator-Mutationen zu erkennen. */ + private static class CapturingProcessingLogger implements ProcessingLogger { + int infoCallCount = 0; + int debugCallCount = 0; + int warnCallCount = 0; + int errorCallCount = 0; + + @Override + public void info(String message, Object... args) { + infoCallCount++; + } + + @Override + public void debug(String message, Object... args) { + debugCallCount++; + } + + @Override + public void warn(String message, Object... args) { + warnCallCount++; + } + + @Override + public void error(String message, Object... args) { + errorCallCount++; + } + } } \ No newline at end of file diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java index c35351d..ac87573 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java @@ -516,6 +516,221 @@ class BatchRunProcessingUseCaseTest { assertFalse(outcome.isSuccess(), "Cannot be SUCCESS when persistence failed for any document"); } + // ------------------------------------------------------------------------- + // Logger-Delegation (VoidMethodCallMutator abdecken) + // ------------------------------------------------------------------------- + + @Test + void execute_fingerprintFailure_logsWarning() throws Exception { + // Prüft, dass bei Fingerprint-Fehler eine Warnung geloggt wird (handleFingerprintError) + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + RuntimeConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidate candidate = makeCandidate("unreadable.pdf"); + FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate)); + + FingerprintPort alwaysFailingFingerprintPort = c -> + new FingerprintTechnicalError("Datei nicht lesbar", null); + + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + config, new MockRunLockPort(), candidatesPort, new NoOpExtractionPort(), + alwaysFailingFingerprintPort, new NoOpDocumentProcessingCoordinator(), + capturingLogger); + + useCase.execute(new BatchRunContext(new RunId("fp-warn"), Instant.now())); + + assertTrue(capturingLogger.warnCallCount > 0, + "Bei Fingerprint-Fehler muss eine Warnung geloggt werden"); + } + + @Test + void execute_sourceAccessException_logsError() throws Exception { + // Prüft, dass bei Quellordner-Zugriffsfehler ein Fehler geloggt wird + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + RuntimeConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidatesPort failingPort = () -> { + throw new SourceDocumentAccessException("Quellordner nicht lesbar"); + }; + + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + config, new MockRunLockPort(), failingPort, new NoOpExtractionPort(), + new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(), + capturingLogger); + + useCase.execute(new BatchRunContext(new RunId("source-err"), Instant.now())); + + assertTrue(capturingLogger.errorCallCount > 0, + "Bei Quellordner-Zugriffsfehler muss ein Fehler geloggt werden"); + } + + @Test + void execute_withPersistenceFailure_logsWarning() throws Exception { + // Prüft, dass nach Batch-Lauf mit Persistenzfehler eine Warnung geloggt wird + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + RuntimeConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidate candidate = makeCandidate("doc.pdf"); + FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate)); + FixedExtractionPort extractionPort = new FixedExtractionPort( + new PdfExtractionSuccess("text", new PdfPageCount(1))); + + // Coordinator der immer Persistenzfehler zurückgibt + DocumentProcessingCoordinator failingCoordinator = new DocumentProcessingCoordinator( + new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), + new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) { + @Override + public boolean processDeferredOutcome( + de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate c, + de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fp, + de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext ctx, + java.time.Instant start, + java.util.function.Function exec) { + return false; + } + }; + + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + config, new MockRunLockPort(), candidatesPort, extractionPort, + new AlwaysSuccessFingerprintPort(), failingCoordinator, capturingLogger); + + useCase.execute(new BatchRunContext(new RunId("persist-warn"), Instant.now())); + + assertTrue(capturingLogger.warnCallCount > 0, + "Nach Batch-Lauf mit Persistenzfehler muss eine Warnung geloggt werden"); + } + + @Test + void execute_batchStart_logsInfo() throws Exception { + // Prüft, dass beim Batch-Start mindestens die erwarteten Info-Einträge geloggt werden. + // Erwartete info()-Aufrufe ohne Kandidaten: execute L130 + execute L145 + processCandidates L178 + L190 = 4 + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + RuntimeConfiguration config = buildConfig(tempDir); + + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + config, new MockRunLockPort(), new EmptyCandidatesPort(), new NoOpExtractionPort(), + new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(), + capturingLogger); + + useCase.execute(new BatchRunContext(new RunId("start-log"), Instant.now())); + + // Ohne Kandidaten: L130 + L145 + L178 + L190 = 4 info()-Aufrufe + assertTrue(capturingLogger.infoCallCount >= 4, + "Batch-Start muss mindestens 4 Info-Einträge loggen (L130/L145/L178/L190), war: " + + capturingLogger.infoCallCount); + } + + @Test + void execute_lockUnavailable_logsWarning() throws Exception { + // Prüft, dass bei nicht verfügbarem Lock eine Warnung geloggt wird + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + RuntimeConfiguration config = buildConfig(tempDir); + + CountingRunLockPort lockPort = new CountingRunLockPort(true); + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(), + new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(), + capturingLogger); + + useCase.execute(new BatchRunContext(new RunId("lock-warn"), Instant.now())); + + assertTrue(capturingLogger.warnCallCount > 0, + "Bei nicht verfügbarem Lock muss eine Warnung geloggt werden"); + } + + @Test + void execute_extractionSuccess_logsPreCheckPassedInfoAndDebug() throws Exception { + // Prüft, dass bei erfolgreich verarbeiteter Datei debug() durch logExtractionResult + // und info() durch logProcessingOutcome aufgerufen wird. + // Erwartete debug()-Aufrufe für einen Kandidaten (success-Pfad): + // L138 (lock acquired) + L249 (processCandidate) + L293 (fingerprint) + L337 (logExtractionResult) + L213 (lock released) = 5 + // Ohne logExtractionResult-Aufruf: 4 + // Erwartete info()-Aufrufe für einen Kandidaten (success-Pfad): + // L130 (initiiert) + L145 (gestartet) + L178 (Kandidaten gefunden) + L365 (PreCheckPassed) + L190 (abgeschlossen) = 5 + // Ohne logProcessingOutcome-Aufruf: 4 + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + RuntimeConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidate candidate = makeCandidate("invoice.pdf"); + PdfExtractionSuccess success = new PdfExtractionSuccess("Rechnungstext", new PdfPageCount(1)); + FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate)); + FixedExtractionPort extractionPort = new FixedExtractionPort(success); + TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator(); + + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + config, new MockRunLockPort(), candidatesPort, extractionPort, + new AlwaysSuccessFingerprintPort(), processor, capturingLogger); + + useCase.execute(new BatchRunContext(new RunId("log-precheck"), Instant.now())); + + // Ohne logExtractionResult wären es 4 debug()-Aufrufe; mit logExtractionResult 5 + assertTrue(capturingLogger.debugCallCount >= 5, + "logExtractionResult muss bei PdfExtractionSuccess debug() aufrufen (erwartet >= 5, war: " + + capturingLogger.debugCallCount + ")"); + // Ohne logProcessingOutcome wären es 4 info()-Aufrufe; mit logProcessingOutcome 5 + assertTrue(capturingLogger.infoCallCount >= 5, + "logProcessingOutcome muss bei PreCheckPassed info() aufrufen (erwartet >= 5, war: " + + capturingLogger.infoCallCount + ")"); + } + + @Test + void execute_extractionContentError_logsDebugAndPreCheckFailedInfo() throws Exception { + // Prüft, dass bei PdfExtractionContentError debug (logExtractionResult) und info (logProcessingOutcome) geloggt wird. + // Erwartete debug()-Aufrufe: 5 (lock + processCandidate + fingerprint + logExtractionResult (content) + lock released) + // Erwartete info()-Aufrufe: 5 (L130 + L145 + L178 + L369 PreCheckFailed + L190) + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + RuntimeConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidate candidate = makeCandidate("encrypted.pdf"); + PdfExtractionContentError contentError = new PdfExtractionContentError("PDF verschlüsselt"); + FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate)); + FixedExtractionPort extractionPort = new FixedExtractionPort(contentError); + TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator(); + + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + config, new MockRunLockPort(), candidatesPort, extractionPort, + new AlwaysSuccessFingerprintPort(), processor, capturingLogger); + + useCase.execute(new BatchRunContext(new RunId("log-content-error"), Instant.now())); + + // Ohne logExtractionResult wären es 4 debug()-Aufrufe; mit logExtractionResult 5 + assertTrue(capturingLogger.debugCallCount >= 5, + "logExtractionResult muss bei PdfExtractionContentError debug() aufrufen (erwartet >= 5, war: " + + capturingLogger.debugCallCount + ")"); + // Ohne logProcessingOutcome (PreCheckFailed) wären es 4 info()-Aufrufe; mit 5 + assertTrue(capturingLogger.infoCallCount >= 5, + "logProcessingOutcome muss bei PreCheckFailed info() aufrufen (erwartet >= 5, war: " + + capturingLogger.infoCallCount + ")"); + } + + @Test + void execute_extractionTechnicalError_logsDebugAndWarn() throws Exception { + // Prüft, dass bei PdfExtractionTechnicalError debug (logExtractionResult) und warn (logProcessingOutcome) geloggt wird. + // Erwartete debug()-Aufrufe: 5 (lock + processCandidate + fingerprint + logExtractionResult + lock released) + CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger(); + RuntimeConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidate candidate = makeCandidate("broken.pdf"); + PdfExtractionTechnicalError technicalError = new PdfExtractionTechnicalError("I/O-Fehler", null); + FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate)); + FixedExtractionPort extractionPort = new FixedExtractionPort(technicalError); + TrackingDocumentProcessingCoordinator processor = new TrackingDocumentProcessingCoordinator(); + + DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase( + config, new MockRunLockPort(), candidatesPort, extractionPort, + new AlwaysSuccessFingerprintPort(), processor, capturingLogger); + + useCase.execute(new BatchRunContext(new RunId("log-tech-error"), Instant.now())); + + // Ohne logExtractionResult wären es 4 debug()-Aufrufe; mit logExtractionResult 5 + assertTrue(capturingLogger.debugCallCount >= 5, + "logExtractionResult muss bei PdfExtractionTechnicalError debug() aufrufen (erwartet >= 5, war: " + + capturingLogger.debugCallCount + ")"); + // logProcessingOutcome ruft warn() auf für TechnicalDocumentError + assertTrue(capturingLogger.warnCallCount > 0, + "logProcessingOutcome muss bei TechnicalDocumentError warn() aufrufen"); + } + // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- @@ -816,4 +1031,32 @@ class BatchRunProcessingUseCaseTest { // No-op } } + + /** Zählt Logger-Aufrufe je Level, um VoidMethodCallMutator-Mutationen zu erkennen. */ + private static class CapturingProcessingLogger implements ProcessingLogger { + int infoCallCount = 0; + int debugCallCount = 0; + int warnCallCount = 0; + int errorCallCount = 0; + + @Override + public void info(String message, Object... args) { + infoCallCount++; + } + + @Override + public void debug(String message, Object... args) { + debugCallCount++; + } + + @Override + public void warn(String message, Object... args) { + warnCallCount++; + } + + @Override + public void error(String message, Object... args) { + errorCallCount++; + } + } } \ No newline at end of file