diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/BatchRunEndToEndTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/BatchRunEndToEndTest.java
index a58d4c7..ebeb7b5 100644
--- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/BatchRunEndToEndTest.java
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/BatchRunEndToEndTest.java
@@ -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.
*
Deterministic content error: 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.
+ * {@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.
* Transient technical error: AI stub failures produce
* {@code FAILED_RETRYABLE} (transient counter incremented) without a target file.
* Transient error exhaustion: 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:
*
- * - Run 1: blank PDF → pre-check fails (no extractable text) →
- * {@code FAILED_RETRYABLE}, content error counter = 1.
- * - Run 2: same outcome again → {@code FAILED_FINAL}, content error counter = 2.
+ * - Run 1: blank PDF → pre-check fails ({@code NO_USABLE_TEXT}) →
+ * {@code FAILED_FINAL} immediately, content error counter = 1.
+ * - Run 2: document is already terminal → {@code SKIPPED_FINAL_FAILURE}.
*
- * 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 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 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 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 {
*
* - 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.
+ * {@code FAILED_FINAL} immediately (no retry for {@code NO_USABLE_TEXT}).
+ * {@link BatchRunOutcome#SUCCESS} is returned.
* 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.
+ * the blank PDF is skipped as {@code SKIPPED_FINAL_FAILURE} (already terminal).
+ * {@link BatchRunOutcome#SUCCESS} is returned.
*
* 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 targetFiles = ctx.listTargetFiles();