diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java index e5e722c..77a2894 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java @@ -33,6 +33,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady; import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; +import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError; import java.time.Instant; import java.util.Objects; @@ -538,12 +539,11 @@ public class DocumentProcessingCoordinator { * Persists a transient error for a document-level technical failure during the * target-copy finalization stage. *

- * The resulting status is {@link ProcessingStatus#FAILED_FINAL} if the incremented - * transient error counter reaches {@code maxRetriesTransient}; otherwise - * {@link ProcessingStatus#FAILED_RETRYABLE}. The transient error counter is always - * incremented by exactly one. This method does not increment the laufübergreifenden - * transient counter for the within-run immediate retry — only the combined outcome - * of the retry is reported here. + * The retry decision (status and updated counters) is derived via the central + * rule in {@link ProcessingOutcomeTransition}, keeping the target-copy finalization + * path consistent with the AI pipeline path. The transient error counter is always + * incremented by exactly one. This method does not count the within-run immediate + * retry — only the combined outcome of the retry is reported here. * * @return true if the error was persisted; false if the error persistence itself failed */ @@ -556,12 +556,16 @@ public class DocumentProcessingCoordinator { Instant now, String errorMessage) { - FailureCounters updatedCounters = - existingRecord.failureCounters().withIncrementedTransientErrorCount(); - boolean limitReached = updatedCounters.transientErrorCount() >= maxRetriesTransient; - ProcessingStatus errorStatus = limitReached - ? ProcessingStatus.FAILED_FINAL - : ProcessingStatus.FAILED_RETRYABLE; + // Delegate to the central retry rule so the target-copy path and the AI pipeline + // path are governed by the same logic without duplication. + ProcessingOutcomeTransition.ProcessingOutcome transition = + ProcessingOutcomeTransition.forKnownDocument( + new TechnicalDocumentError(candidate, errorMessage, null), + existingRecord.failureCounters(), + maxRetriesTransient); + FailureCounters updatedCounters = transition.counters(); + ProcessingStatus errorStatus = transition.overallStatus(); + boolean retryable = transition.retryable(); try { int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint); @@ -569,7 +573,7 @@ public class DocumentProcessingCoordinator { fingerprint, context.runId(), attemptNumber, attemptStart, now, errorStatus, errorStatus.name(), - errorMessage, !limitReached); + errorMessage, retryable); DocumentRecord errorRecord = buildTransientErrorRecord( existingRecord, candidate, updatedCounters, errorStatus, now); @@ -579,7 +583,7 @@ public class DocumentProcessingCoordinator { txOps.updateDocumentRecord(errorRecord); }); - if (limitReached) { + if (!retryable) { logger.info("Retry decision for '{}' (fingerprint: {}): FAILED_FINAL — " + "transient error limit reached ({}/{} attempts). No further retry.", candidate.uniqueIdentifier(), fingerprint.sha256Hex(), @@ -604,9 +608,10 @@ public class DocumentProcessingCoordinator { * following a successful target copy. This is a secondary persistence effort; * its failure is logged but does not change the return value. *

- * Applies the same transient limit check as {@link #persistTransientError}: if the - * incremented counter reaches {@code maxRetriesTransient}, the secondary attempt - * is persisted as {@link ProcessingStatus#FAILED_FINAL}. + * Applies the same transient limit check as {@link #persistTransientError} via the + * central rule in {@link ProcessingOutcomeTransition}: if the incremented counter + * reaches {@code maxRetriesTransient}, the secondary attempt is persisted as + * {@link ProcessingStatus#FAILED_FINAL}. */ private void persistTransientErrorAfterPersistenceFailure( SourceDocumentCandidate candidate, @@ -617,12 +622,13 @@ public class DocumentProcessingCoordinator { Instant now, String errorMessage) { - FailureCounters updatedCounters = - existingRecord.failureCounters().withIncrementedTransientErrorCount(); - boolean limitReached = updatedCounters.transientErrorCount() >= maxRetriesTransient; - ProcessingStatus errorStatus = limitReached - ? ProcessingStatus.FAILED_FINAL - : ProcessingStatus.FAILED_RETRYABLE; + ProcessingOutcomeTransition.ProcessingOutcome transition = + ProcessingOutcomeTransition.forKnownDocument( + new TechnicalDocumentError(candidate, errorMessage, null), + existingRecord.failureCounters(), + maxRetriesTransient); + FailureCounters updatedCounters = transition.counters(); + ProcessingStatus errorStatus = transition.overallStatus(); try { int attemptNumber = processingAttemptRepository.loadNextAttemptNumber(fingerprint); @@ -630,7 +636,7 @@ public class DocumentProcessingCoordinator { fingerprint, context.runId(), attemptNumber, attemptStart, now, errorStatus, errorStatus.name(), - errorMessage, !limitReached); + errorMessage, transition.retryable()); DocumentRecord errorRecord = buildTransientErrorRecord( existingRecord, candidate, updatedCounters, errorStatus, now); diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransition.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransition.java index 289f067..8897f00 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransition.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransition.java @@ -10,11 +10,13 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError; /** - * Pure status and counter transition policy for document processing outcomes. + * Authoritative, stateless retry decision rule for all document processing outcomes. *

- * This class encapsulates the deterministic rules for mapping a pipeline outcome - * (pre-check, naming proposal, or failure) to a processing status, updated - * failure counters, and retryability flag. + * This class is the single production source of truth for mapping a pipeline outcome + * (pre-check, naming proposal, or failure) to a processing status, updated failure + * counters, and retryability flag. Both the AI pipeline path and the target-copy + * finalization path in {@link DocumentProcessingCoordinator} delegate to this class, + * so that no duplicate retry logic exists elsewhere. *

* The transition logic is independent of persistence, orchestration, or any * infrastructure concern. It is purely declarative and stateless. 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 a00b210..ad42434 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 @@ -1022,6 +1022,39 @@ class DocumentProcessingCoordinatorTest { "copyToTarget must have been called exactly twice: first attempt + one immediate retry"); } + @Test + void processDeferredOutcome_proposalReady_bothCopyAttemptsFail_maxRetriesTransient1_persistsFailedFinal() { + // maxRetriesTransient=1: both copy attempts fail → the transient error immediately finalises + // because the central retry rule (ProcessingOutcomeTransition) treats the first cross-run + // transient error as FAILED_FINAL when the limit is 1. + // The within-run immediate retry does NOT count toward this limit. + DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero()); + recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); + attemptRepo.savedAttempts.add(buildValidProposalAttempt()); + + CountingTargetFileCopyPort failingCopy = new CountingTargetFileCopyPort(2); // fail both + DocumentProcessingCoordinator coordinatorWith1Retry = new DocumentProcessingCoordinator( + recordRepo, attemptRepo, unitOfWorkPort, + new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1); + + coordinatorWith1Retry.processDeferredOutcome( + candidate, fingerprint, context, attemptStart, c -> null); + + ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream() + .filter(a -> a.status() == ProcessingStatus.FAILED_FINAL) + .findFirst() + .orElse(null); + assertNotNull(errorAttempt, + "With maxRetriesTransient=1, both copy attempts failing must produce FAILED_FINAL"); + assertFalse(errorAttempt.retryable(), + "With maxRetriesTransient=1, the error must not be retryable"); + + DocumentRecord updated = recordRepo.updatedRecords.get(0); + assertEquals(ProcessingStatus.FAILED_FINAL, updated.overallStatus()); + assertEquals(1, updated.failureCounters().transientErrorCount(), + "Transient error counter must be 1 after the first cross-run transient error"); + } + @Test void processDeferredOutcome_proposalReady_immediateRetryDoesNotTriggerAiOrNewProposal() { // Ensures that during the immediate retry path no pipeline (AI) execution happens diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransitionTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransitionTest.java new file mode 100644 index 0000000..2126e11 --- /dev/null +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/ProcessingOutcomeTransitionTest.java @@ -0,0 +1,317 @@ +package de.gecheckt.pdf.umbenenner.application.service; + +import de.gecheckt.pdf.umbenenner.application.port.out.FailureCounters; +import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure; +import de.gecheckt.pdf.umbenenner.domain.model.AiAttemptContext; +import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure; +import de.gecheckt.pdf.umbenenner.domain.model.DateSource; +import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal; +import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady; +import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed; +import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailureReason; +import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator; +import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError; + +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Unit tests for {@link ProcessingOutcomeTransition} — the authoritative central retry rule. + *

+ * These tests prove that: + *

+ *

+ * Skip events ({@code SKIPPED_ALREADY_PROCESSED}, {@code SKIPPED_FINAL_FAILURE}) are not + * routed through this class — they never modify any failure counter. + */ +class ProcessingOutcomeTransitionTest { + + // ------------------------------------------------------------------------- + // Test fixtures + // ------------------------------------------------------------------------- + + private static final int LIMIT_1 = 1; + private static final int LIMIT_2 = 2; + private static final int LIMIT_3 = 3; + + private static SourceDocumentCandidate candidate() { + return new SourceDocumentCandidate("test.pdf", 1024L, + new SourceDocumentLocator("/tmp/test.pdf")); + } + + private static AiAttemptContext aiContext() { + return new AiAttemptContext("model", "prompt.txt", 1, 100, "{}"); + } + + // ------------------------------------------------------------------------- + // Naming proposal → PROPOSAL_READY (counters unchanged) + // ------------------------------------------------------------------------- + + @Test + void forNewDocument_namingProposalReady_returnsProposalReady_countersUnchanged() { + NamingProposal proposal = new NamingProposal( + LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "reason"); + NamingProposalReady outcome = new NamingProposalReady(candidate(), proposal, aiContext()); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1); + + assertEquals(ProcessingStatus.PROPOSAL_READY, result.overallStatus()); + assertFalse(result.retryable()); + assertEquals(0, result.counters().contentErrorCount()); + assertEquals(0, result.counters().transientErrorCount()); + } + + @Test + void forKnownDocument_namingProposalReady_countersUnchangedFromExisting() { + NamingProposal proposal = new NamingProposal( + LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "reason"); + NamingProposalReady outcome = new NamingProposalReady(candidate(), proposal, aiContext()); + FailureCounters existing = new FailureCounters(1, 2); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_3); + + assertEquals(ProcessingStatus.PROPOSAL_READY, result.overallStatus()); + assertFalse(result.retryable()); + // Counters must be preserved unchanged on a successful naming proposal + assertEquals(1, result.counters().contentErrorCount(), + "Content error counter must remain unchanged on PROPOSAL_READY"); + assertEquals(2, result.counters().transientErrorCount(), + "Transient error counter must remain unchanged on PROPOSAL_READY"); + } + + // ------------------------------------------------------------------------- + // Deterministic content errors (PreCheckFailed) + // ------------------------------------------------------------------------- + + @Test + void forNewDocument_firstPreCheckFailed_returnsFailedRetryable_contentCounterOne() { + PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1); + + assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus()); + assertTrue(result.retryable()); + assertEquals(1, result.counters().contentErrorCount()); + assertEquals(0, result.counters().transientErrorCount()); + } + + @Test + void forKnownDocument_secondPreCheckFailed_returnsFailedFinal_contentCounterTwo() { + PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.PAGE_LIMIT_EXCEEDED); + FailureCounters existing = new FailureCounters(1, 0); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_1); + + assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus()); + assertFalse(result.retryable()); + assertEquals(2, result.counters().contentErrorCount()); + assertEquals(0, result.counters().transientErrorCount()); + } + + @Test + void forKnownDocument_anySubsequentContentError_remainsFailedFinal() { + // Count >= 1 before the attempt always leads to FAILED_FINAL (covers legacy data) + for (int priorCount = 1; priorCount <= 5; priorCount++) { + FailureCounters existing = new FailureCounters(priorCount, 0); + PreCheckFailed outcome = + new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2); + + assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(), + "Expected FAILED_FINAL for prior contentErrorCount=" + priorCount); + assertFalse(result.retryable()); + assertEquals(priorCount + 1, result.counters().contentErrorCount()); + } + } + + @Test + void forNewDocument_contentError_transientCounterIsIrrelevant() { + PreCheckFailed outcome = new PreCheckFailed(candidate(), PreCheckFailureReason.NO_USABLE_TEXT); + + // Counter before: 0 content errors (first occurrence), transient ignored + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument( + outcome, new FailureCounters(0, 5), LIMIT_1); + + assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(), + "Non-zero transient counter must not affect the content error decision"); + assertTrue(result.retryable()); + } + + // ------------------------------------------------------------------------- + // Deterministic content errors (AiFunctionalFailure) + // ------------------------------------------------------------------------- + + @Test + void forNewDocument_firstAiFunctionalFailure_returnsFailedRetryable_contentCounterOne() { + AiFunctionalFailure outcome = new AiFunctionalFailure(candidate(), "Generic title", aiContext()); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1); + + assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus()); + assertTrue(result.retryable()); + assertEquals(1, result.counters().contentErrorCount()); + } + + @Test + void forKnownDocument_secondAiFunctionalFailure_returnsFailedFinal() { + AiFunctionalFailure outcome = new AiFunctionalFailure(candidate(), "Generic title", aiContext()); + FailureCounters existing = new FailureCounters(1, 0); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_1); + + assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus()); + assertFalse(result.retryable()); + assertEquals(2, result.counters().contentErrorCount()); + } + + // ------------------------------------------------------------------------- + // Transient technical errors (TechnicalDocumentError) + // ------------------------------------------------------------------------- + + @Test + void forNewDocument_transientError_limitOne_immediatelyFinal() { + TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "I/O error", null); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1); + + assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(), + "With limit=1, the first transient error must immediately finalise"); + assertFalse(result.retryable()); + assertEquals(1, result.counters().transientErrorCount()); + assertEquals(0, result.counters().contentErrorCount()); + } + + @Test + void forNewDocument_transientError_limitTwo_firstErrorRetryable() { + TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "Timeout", null); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_2); + + assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus()); + assertTrue(result.retryable()); + assertEquals(1, result.counters().transientErrorCount()); + } + + @Test + void forKnownDocument_transientError_limitTwo_secondErrorFinal() { + TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "Timeout again", null); + FailureCounters existing = new FailureCounters(0, 1); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2); + + assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(), + "Second transient error with limit=2 must finalise"); + assertFalse(result.retryable()); + assertEquals(2, result.counters().transientErrorCount()); + } + + @Test + void forKnownDocument_transientError_limitThree_sequence() { + TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null); + + // First error: counter 0→1, 1 < 3 → retryable + ProcessingOutcomeTransition.ProcessingOutcome first = + ProcessingOutcomeTransition.forKnownDocument( + outcome, new FailureCounters(0, 0), LIMIT_3); + assertEquals(ProcessingStatus.FAILED_RETRYABLE, first.overallStatus()); + assertEquals(1, first.counters().transientErrorCount()); + + // Second error: counter 1→2, 2 < 3 → retryable + ProcessingOutcomeTransition.ProcessingOutcome second = + ProcessingOutcomeTransition.forKnownDocument( + outcome, new FailureCounters(0, 1), LIMIT_3); + assertEquals(ProcessingStatus.FAILED_RETRYABLE, second.overallStatus()); + assertEquals(2, second.counters().transientErrorCount()); + + // Third error: counter 2→3 = limit=3 → FAILED_FINAL + ProcessingOutcomeTransition.ProcessingOutcome third = + ProcessingOutcomeTransition.forKnownDocument( + outcome, new FailureCounters(0, 2), LIMIT_3); + assertEquals(ProcessingStatus.FAILED_FINAL, third.overallStatus(), + "Third transient error with limit=3 must finalise"); + assertFalse(third.retryable()); + assertEquals(3, third.counters().transientErrorCount()); + } + + @Test + void forKnownDocument_transientError_legacyHighCounters_stillFinalise() { + // Legacy data from M4-M6 may have counters well above normal expectations. + // The threshold check must still apply correctly. + TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null); + FailureCounters existing = new FailureCounters(3, 10); // already far above limit + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_3); + + assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(), + "Transient counter above limit must still produce FAILED_FINAL"); + assertEquals(11, result.counters().transientErrorCount()); + } + + @Test + void forNewDocument_transientError_contentCounterIsIrrelevant() { + TechnicalDocumentError outcome = new TechnicalDocumentError(candidate(), "error", null); + + // Non-zero content error counter must not affect the transient error decision + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument( + outcome, new FailureCounters(2, 0), LIMIT_2); + + assertEquals(ProcessingStatus.FAILED_RETRYABLE, result.overallStatus(), + "Content error counter must not affect transient error decision"); + assertEquals(1, result.counters().transientErrorCount()); + } + + // ------------------------------------------------------------------------- + // Transient technical errors (AiTechnicalFailure) + // ------------------------------------------------------------------------- + + @Test + void forNewDocument_aiTechnicalFailure_limitOne_immediatelyFinal() { + AiTechnicalFailure outcome = new AiTechnicalFailure(candidate(), "HTTP timeout", null, aiContext()); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forNewDocument(outcome, LIMIT_1); + + assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus(), + "With limit=1, first AI technical failure must immediately finalise"); + assertFalse(result.retryable()); + assertEquals(1, result.counters().transientErrorCount()); + } + + @Test + void forKnownDocument_aiTechnicalFailure_limitTwo_secondFinal() { + AiTechnicalFailure outcome = new AiTechnicalFailure(candidate(), "HTTP timeout", null, aiContext()); + FailureCounters existing = new FailureCounters(0, 1); + + ProcessingOutcomeTransition.ProcessingOutcome result = + ProcessingOutcomeTransition.forKnownDocument(outcome, existing, LIMIT_2); + + assertEquals(ProcessingStatus.FAILED_FINAL, result.overallStatus()); + assertEquals(2, result.counters().transientErrorCount()); + } +}