1
0

M4 Nachbearbeitung Anwendungskern testseitig geschärft

This commit is contained in:
2026-04-06 14:37:47 +02:00
parent 707364d912
commit efc13d841e
2 changed files with 630 additions and 0 deletions

View File

@@ -356,6 +356,365 @@ class DocumentProcessingCoordinatorTest {
"Attempt number must be taken from the repository"); "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 // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -490,4 +849,32 @@ class DocumentProcessingCoordinatorTest {
// No-op // 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++;
}
}
} }

View File

@@ -516,6 +516,221 @@ class BatchRunProcessingUseCaseTest {
assertFalse(outcome.isSuccess(), "Cannot be SUCCESS when persistence failed for any document"); 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<de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate, de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome> 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 // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -816,4 +1031,32 @@ class BatchRunProcessingUseCaseTest {
// No-op // 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++;
}
}
} }