#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:
2026-05-04 15:37:36 +02:00
parent 18f9c33bbb
commit bd2be347f6
@@ -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();