M7 Zentrale Retry-Entscheidung vervollständigt und vereinheitlicht
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
* <p>
|
||||
* These tests prove that:
|
||||
* <ul>
|
||||
* <li>Deterministic content errors follow the first-retryable / second-final rule.</li>
|
||||
* <li>Transient technical errors respect the configured {@code maxRetriesTransient} limit.</li>
|
||||
* <li>{@code maxRetriesTransient = 1} immediately finalises on the first transient error.</li>
|
||||
* <li>Naming proposals yield {@code PROPOSAL_READY} with unchanged counters.</li>
|
||||
* <li>AI functional failures are governed by the same content-error rule.</li>
|
||||
* <li>AI technical failures are governed by the same transient-error rule.</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user