#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
* after the first run.</li>
* <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
* second run, exercising the one-retry rule for deterministic content errors.</li>
* {@code FAILED_FINAL} immediately after the first run, because {@code NO_USABLE_TEXT}
* 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
* {@code FAILED_RETRYABLE} (transient counter incremented) without a target file.</li>
* <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>
* <li>Run 1: blank PDF → pre-check fails (no extractable text) →
* {@code FAILED_RETRYABLE}, content error counter = 1.</li>
* <li>Run 2: same outcome again → {@code FAILED_FINAL}, content error counter = 2.</li>
* <li>Run 1: blank PDF → pre-check fails ({@code NO_USABLE_TEXT}) →
* {@code FAILED_FINAL} immediately, content error counter = 1.</li>
* <li>Run 2: document is already terminal → {@code SKIPPED_FINAL_FAILURE}.</li>
* </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
void deterministicContentError_twoRuns_reachesFailedFinal(@TempDir Path tempDir)
void deterministicContentError_noUsableText_immediatelyFailedFinal(@TempDir Path tempDir)
throws Exception {
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
ctx.createBlankPdf("blank.pdf");
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// --- Run 1 ---
// --- Run 1: NO_USABLE_TEXT → FAILED_FINAL immediately ---
ctx.runBatch();
assertThat(ctx.aiStub.invocationCount())
@@ -152,26 +154,29 @@ class BatchRunEndToEndTest {
.isEqualTo(0);
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().transientErrorCount()).isEqualTo(0);
List<ProcessingAttempt> attempts1 = ctx.findAttempts(fp);
assertThat(attempts1).hasSize(1);
assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
assertThat(attempts1.get(0).retryable()).isTrue();
assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
assertThat(attempts1.get(0).retryable()).isFalse();
// --- Run 2 ---
// --- Run 2: terminal FAILED_FINAL → skip ---
ctx.runBatch();
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
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);
assertThat(attempts2).hasSize(2);
assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.FAILED_FINAL);
assertThat(attempts2.get(1).retryable()).isFalse();
assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
// No target file should exist
assertThat(ctx.listTargetFiles()).isEmpty();
@@ -277,40 +282,39 @@ class BatchRunEndToEndTest {
/**
* 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
* or failure counters.
*/
@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)) {
ctx.createBlankPdf("blank.pdf");
Path pdfPath = ctx.sourceFolder().resolve("blank.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// Reach FAILED_FINAL via two blank-PDF runs
ctx.runBatch(); // → FAILED_RETRYABLE
// Reach FAILED_FINAL in a single blank-PDF run (NO_USABLE_TEXT finalises immediately)
ctx.runBatch(); // → FAILED_FINAL
DocumentRecord finalRecord = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(finalRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
int contentErrorsBefore = finalRecord.failureCounters().contentErrorCount();
// --- Run 3: should produce skip ---
// --- Run 2: should produce skip ---
ctx.runBatch();
DocumentRecord record3 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record3.overallStatus())
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record2.overallStatus())
.as("Overall status must remain FAILED_FINAL after a skip")
.isEqualTo(ProcessingStatus.FAILED_FINAL);
assertThat(record3.failureCounters().contentErrorCount())
assertThat(record2.failureCounters().contentErrorCount())
.as("Failure counters must not change after a skip")
.isEqualTo(contentErrorsBefore);
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(3);
assertThat(attempts.get(2).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
assertThat(attempts.get(2).retryable()).isFalse();
assertThat(attempts).hasSize(2);
assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SKIPPED_FINAL_FAILURE);
assertThat(attempts.get(1).retryable()).isFalse();
}
}
@@ -579,10 +583,11 @@ class BatchRunEndToEndTest {
* <ol>
* <li>Run 1: a searchable PDF reaches {@code SUCCESS} within the same run
* (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};
* the blank PDF reaches its second content error and is finalized to
* {@code FAILED_FINAL}. {@link BatchRunOutcome#SUCCESS} is returned.</li>
* the blank PDF is skipped as {@code SKIPPED_FINAL_FAILURE} (already terminal).
* {@link BatchRunOutcome#SUCCESS} is returned.</li>
* </ol>
* This confirms the exit-code contract: only hard bootstrap or infrastructure
* 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")
.isEqualTo(ProcessingStatus.SUCCESS);
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()
.failureCounters().contentErrorCount()).isEqualTo(1);
@@ -622,8 +628,7 @@ class BatchRunEndToEndTest {
BatchRunOutcome run2 = ctx.runBatch();
assertThat(run2)
.as("Batch must complete with SUCCESS even when a document is finalised "
+ "to FAILED_FINAL")
.as("Batch must complete with SUCCESS when all documents are skipped")
.isEqualTo(BatchRunOutcome.SUCCESS);
DocumentRecord goodRecord = ctx.findDocumentRecord(fpGood).orElseThrow();
@@ -632,8 +637,12 @@ class BatchRunEndToEndTest {
.isEqualTo(ProcessingStatus.SUCCESS);
DocumentRecord blankRecord = ctx.findDocumentRecord(fpBlank).orElseThrow();
assertThat(blankRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
assertThat(blankRecord.failureCounters().contentErrorCount()).isEqualTo(2);
assertThat(blankRecord.overallStatus())
.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
List<String> targetFiles = ctx.listTargetFiles();