#78: E2E-Tests auf sofortiges FAILED_FINAL fuer NO_USABLE_TEXT angepasst
Nach dem #78-Fix finalisiert NO_USABLE_TEXT (Foto-PDF) sofort zu FAILED_FINAL. Die drei betroffenen E2E-Testszenarien im Bootstrap-Modul erwarteten noch das alte FAILED_RETRYABLE-Verhalten: - deterministicContentError_twoRuns_reachesFailedFinal umgeschrieben: Run 1 erwartet jetzt sofort FAILED_FINAL, Run 2 erwartet SKIPPED_FINAL_FAILURE. - skipAfterFailedFinal_thirdRun_recordsSkip umbenannt zu _secondRun_: FAILED_FINAL ist nach einem Lauf erreicht, der Skip folgt im zweiten Lauf. - mixedBatch_oneSuccess_oneContentError_batchOutcomeIsSuccess korrigiert: Run 1 erwartet FAILED_FINAL (nicht FAILED_RETRYABLE), Run 2 erwartet SKIPPED_FINAL_FAILURE (nicht einen zweiten Inhaltsfehler). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+45
-36
@@ -33,8 +33,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
|
|||||||
* the document's master record reaches {@code SUCCESS} and the target file is on disk
|
* the document's master record reaches {@code SUCCESS} and the target file is on disk
|
||||||
* after the first run.</li>
|
* after the first run.</li>
|
||||||
* <li><strong>Deterministic content error</strong>: blank PDFs (no extractable text) reach
|
* <li><strong>Deterministic content error</strong>: blank PDFs (no extractable text) reach
|
||||||
* {@code FAILED_RETRYABLE} after the first run and {@code FAILED_FINAL} after the
|
* {@code FAILED_FINAL} immediately after the first run, because {@code NO_USABLE_TEXT}
|
||||||
* second run, exercising the one-retry rule for deterministic content errors.</li>
|
* is not retryable — an image-only scan without OCR text will not change on retry.</li>
|
||||||
* <li><strong>Transient technical error</strong>: AI stub failures produce
|
* <li><strong>Transient technical error</strong>: AI stub failures produce
|
||||||
* {@code FAILED_RETRYABLE} (transient counter incremented) without a target file.</li>
|
* {@code FAILED_RETRYABLE} (transient counter incremented) without a target file.</li>
|
||||||
* <li><strong>Transient error exhaustion</strong>: repeated AI stub failures across
|
* <li><strong>Transient error exhaustion</strong>: repeated AI stub failures across
|
||||||
@@ -124,27 +124,29 @@ class BatchRunEndToEndTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Scenario 2: Deterministic content error → FAILED_RETRYABLE → FAILED_FINAL
|
// Scenario 2: Deterministic content error (NO_USABLE_TEXT) → immediate FAILED_FINAL
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the one-retry rule for deterministic content errors:
|
* Verifies that a blank PDF (no extractable text) reaches {@code FAILED_FINAL} immediately
|
||||||
|
* in a single run without any retry:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Run 1: blank PDF → pre-check fails (no extractable text) →
|
* <li>Run 1: blank PDF → pre-check fails ({@code NO_USABLE_TEXT}) →
|
||||||
* {@code FAILED_RETRYABLE}, content error counter = 1.</li>
|
* {@code FAILED_FINAL} immediately, content error counter = 1.</li>
|
||||||
* <li>Run 2: same outcome again → {@code FAILED_FINAL}, content error counter = 2.</li>
|
* <li>Run 2: document is already terminal → {@code SKIPPED_FINAL_FAILURE}.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
* No AI call is made in either run because the content pre-check prevents it.
|
* No AI call is made because the content pre-check prevents it.
|
||||||
|
* An image-only scan without OCR text will not change between runs, so no retry is useful.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void deterministicContentError_twoRuns_reachesFailedFinal(@TempDir Path tempDir)
|
void deterministicContentError_noUsableText_immediatelyFailedFinal(@TempDir Path tempDir)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
|
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
|
||||||
ctx.createBlankPdf("blank.pdf");
|
ctx.createBlankPdf("blank.pdf");
|
||||||
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
|
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
|
||||||
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
|
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
|
||||||
|
|
||||||
// --- Run 1 ---
|
// --- Run 1: NO_USABLE_TEXT → FAILED_FINAL immediately ---
|
||||||
ctx.runBatch();
|
ctx.runBatch();
|
||||||
|
|
||||||
assertThat(ctx.aiStub.invocationCount())
|
assertThat(ctx.aiStub.invocationCount())
|
||||||
@@ -152,26 +154,29 @@ class BatchRunEndToEndTest {
|
|||||||
.isEqualTo(0);
|
.isEqualTo(0);
|
||||||
|
|
||||||
DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
|
DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
|
||||||
assertThat(record1.overallStatus()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
assertThat(record1.overallStatus())
|
||||||
|
.as("Blank PDF must finalise immediately without retry")
|
||||||
|
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(record1.failureCounters().contentErrorCount()).isEqualTo(1);
|
assertThat(record1.failureCounters().contentErrorCount()).isEqualTo(1);
|
||||||
assertThat(record1.failureCounters().transientErrorCount()).isEqualTo(0);
|
assertThat(record1.failureCounters().transientErrorCount()).isEqualTo(0);
|
||||||
|
|
||||||
List<ProcessingAttempt> attempts1 = ctx.findAttempts(fp);
|
List<ProcessingAttempt> attempts1 = ctx.findAttempts(fp);
|
||||||
assertThat(attempts1).hasSize(1);
|
assertThat(attempts1).hasSize(1);
|
||||||
assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(attempts1.get(0).retryable()).isTrue();
|
assertThat(attempts1.get(0).retryable()).isFalse();
|
||||||
|
|
||||||
// --- Run 2 ---
|
// --- Run 2: terminal FAILED_FINAL → skip ---
|
||||||
ctx.runBatch();
|
ctx.runBatch();
|
||||||
|
|
||||||
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
|
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
|
||||||
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(record2.failureCounters().contentErrorCount()).isEqualTo(2);
|
assertThat(record2.failureCounters().contentErrorCount())
|
||||||
|
.as("Content error counter must not change after a skip")
|
||||||
|
.isEqualTo(1);
|
||||||
|
|
||||||
List<ProcessingAttempt> attempts2 = ctx.findAttempts(fp);
|
List<ProcessingAttempt> attempts2 = ctx.findAttempts(fp);
|
||||||
assertThat(attempts2).hasSize(2);
|
assertThat(attempts2).hasSize(2);
|
||||||
assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
|
||||||
assertThat(attempts2.get(1).retryable()).isFalse();
|
|
||||||
|
|
||||||
// No target file should exist
|
// No target file should exist
|
||||||
assertThat(ctx.listTargetFiles()).isEmpty();
|
assertThat(ctx.listTargetFiles()).isEmpty();
|
||||||
@@ -277,40 +282,39 @@ class BatchRunEndToEndTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies the skip-after-final-failure invariant:
|
* Verifies the skip-after-final-failure invariant:
|
||||||
* after a document reaches {@code FAILED_FINAL} (via two blank-PDF runs), a third run
|
* after a document reaches {@code FAILED_FINAL} (in a single blank-PDF run), a second run
|
||||||
* records a {@code SKIPPED_FINAL_FAILURE} attempt without changing the overall status
|
* records a {@code SKIPPED_FINAL_FAILURE} attempt without changing the overall status
|
||||||
* or failure counters.
|
* or failure counters.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void skipAfterFailedFinal_thirdRun_recordsSkip(@TempDir Path tempDir) throws Exception {
|
void skipAfterFailedFinal_secondRun_recordsSkip(@TempDir Path tempDir) throws Exception {
|
||||||
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
|
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
|
||||||
ctx.createBlankPdf("blank.pdf");
|
ctx.createBlankPdf("blank.pdf");
|
||||||
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
|
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
|
||||||
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
|
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
|
||||||
|
|
||||||
// Reach FAILED_FINAL via two blank-PDF runs
|
// Reach FAILED_FINAL in a single blank-PDF run (NO_USABLE_TEXT finalises immediately)
|
||||||
ctx.runBatch(); // → FAILED_RETRYABLE
|
|
||||||
ctx.runBatch(); // → FAILED_FINAL
|
ctx.runBatch(); // → FAILED_FINAL
|
||||||
|
|
||||||
DocumentRecord finalRecord = ctx.findDocumentRecord(fp).orElseThrow();
|
DocumentRecord finalRecord = ctx.findDocumentRecord(fp).orElseThrow();
|
||||||
assertThat(finalRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
assertThat(finalRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
int contentErrorsBefore = finalRecord.failureCounters().contentErrorCount();
|
int contentErrorsBefore = finalRecord.failureCounters().contentErrorCount();
|
||||||
|
|
||||||
// --- Run 3: should produce skip ---
|
// --- Run 2: should produce skip ---
|
||||||
ctx.runBatch();
|
ctx.runBatch();
|
||||||
|
|
||||||
DocumentRecord record3 = ctx.findDocumentRecord(fp).orElseThrow();
|
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
|
||||||
assertThat(record3.overallStatus())
|
assertThat(record2.overallStatus())
|
||||||
.as("Overall status must remain FAILED_FINAL after a skip")
|
.as("Overall status must remain FAILED_FINAL after a skip")
|
||||||
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(record3.failureCounters().contentErrorCount())
|
assertThat(record2.failureCounters().contentErrorCount())
|
||||||
.as("Failure counters must not change after a skip")
|
.as("Failure counters must not change after a skip")
|
||||||
.isEqualTo(contentErrorsBefore);
|
.isEqualTo(contentErrorsBefore);
|
||||||
|
|
||||||
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
|
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
|
||||||
assertThat(attempts).hasSize(3);
|
assertThat(attempts).hasSize(2);
|
||||||
assertThat(attempts.get(2).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
|
assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
|
||||||
assertThat(attempts.get(2).retryable()).isFalse();
|
assertThat(attempts.get(1).retryable()).isFalse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,10 +583,11 @@ class BatchRunEndToEndTest {
|
|||||||
* <ol>
|
* <ol>
|
||||||
* <li>Run 1: a searchable PDF reaches {@code SUCCESS} within the same run
|
* <li>Run 1: a searchable PDF reaches {@code SUCCESS} within the same run
|
||||||
* (PROPOSAL_READY → SUCCESS); a blank PDF (no extractable text) reaches
|
* (PROPOSAL_READY → SUCCESS); a blank PDF (no extractable text) reaches
|
||||||
* {@code FAILED_RETRYABLE}. {@link BatchRunOutcome#SUCCESS} is returned.</li>
|
* {@code FAILED_FINAL} immediately (no retry for {@code NO_USABLE_TEXT}).
|
||||||
|
* {@link BatchRunOutcome#SUCCESS} is returned.</li>
|
||||||
* <li>Run 2: the searchable PDF is skipped as {@code SKIPPED_ALREADY_PROCESSED};
|
* <li>Run 2: the searchable PDF is skipped as {@code SKIPPED_ALREADY_PROCESSED};
|
||||||
* the blank PDF reaches its second content error and is finalized to
|
* the blank PDF is skipped as {@code SKIPPED_FINAL_FAILURE} (already terminal).
|
||||||
* {@code FAILED_FINAL}. {@link BatchRunOutcome#SUCCESS} is returned.</li>
|
* {@link BatchRunOutcome#SUCCESS} is returned.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
* This confirms the exit-code contract: only hard bootstrap or infrastructure
|
* This confirms the exit-code contract: only hard bootstrap or infrastructure
|
||||||
* failures produce a non-zero exit code; document-level errors do not.
|
* failures produce a non-zero exit code; document-level errors do not.
|
||||||
@@ -609,7 +614,8 @@ class BatchRunEndToEndTest {
|
|||||||
.as("Searchable PDF must reach SUCCESS within the same single run")
|
.as("Searchable PDF must reach SUCCESS within the same single run")
|
||||||
.isEqualTo(ProcessingStatus.SUCCESS);
|
.isEqualTo(ProcessingStatus.SUCCESS);
|
||||||
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow().overallStatus())
|
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow().overallStatus())
|
||||||
.isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
|
.as("Blank PDF (NO_USABLE_TEXT) must finalise immediately to FAILED_FINAL")
|
||||||
|
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow()
|
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow()
|
||||||
.failureCounters().contentErrorCount()).isEqualTo(1);
|
.failureCounters().contentErrorCount()).isEqualTo(1);
|
||||||
|
|
||||||
@@ -622,8 +628,7 @@ class BatchRunEndToEndTest {
|
|||||||
BatchRunOutcome run2 = ctx.runBatch();
|
BatchRunOutcome run2 = ctx.runBatch();
|
||||||
|
|
||||||
assertThat(run2)
|
assertThat(run2)
|
||||||
.as("Batch must complete with SUCCESS even when a document is finalised "
|
.as("Batch must complete with SUCCESS when all documents are skipped")
|
||||||
+ "to FAILED_FINAL")
|
|
||||||
.isEqualTo(BatchRunOutcome.SUCCESS);
|
.isEqualTo(BatchRunOutcome.SUCCESS);
|
||||||
|
|
||||||
DocumentRecord goodRecord = ctx.findDocumentRecord(fpGood).orElseThrow();
|
DocumentRecord goodRecord = ctx.findDocumentRecord(fpGood).orElseThrow();
|
||||||
@@ -632,8 +637,12 @@ class BatchRunEndToEndTest {
|
|||||||
.isEqualTo(ProcessingStatus.SUCCESS);
|
.isEqualTo(ProcessingStatus.SUCCESS);
|
||||||
|
|
||||||
DocumentRecord blankRecord = ctx.findDocumentRecord(fpBlank).orElseThrow();
|
DocumentRecord blankRecord = ctx.findDocumentRecord(fpBlank).orElseThrow();
|
||||||
assertThat(blankRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
|
assertThat(blankRecord.overallStatus())
|
||||||
assertThat(blankRecord.failureCounters().contentErrorCount()).isEqualTo(2);
|
.as("Terminal FAILED_FINAL must remain unchanged after skip")
|
||||||
|
.isEqualTo(ProcessingStatus.FAILED_FINAL);
|
||||||
|
assertThat(blankRecord.failureCounters().contentErrorCount())
|
||||||
|
.as("Content error counter must not change after SKIPPED_FINAL_FAILURE")
|
||||||
|
.isEqualTo(1);
|
||||||
|
|
||||||
// Exactly one target file from the successfully processed document
|
// Exactly one target file from the successfully processed document
|
||||||
List<String> targetFiles = ctx.listTargetFiles();
|
List<String> targetFiles = ctx.listTargetFiles();
|
||||||
|
|||||||
Reference in New Issue
Block a user