Fix: Verarbeitung von PROPOSAL_READY bis SUCCESS in einem Lauf; log4j-core im GUI-Test-Classpath

Der Dokument-Processing-Coordinator finalisiert jetzt unmittelbar nach dem
Persistieren des PROPOSAL_READY-Versuchs im selben Lauf zur Zielkopie und zu
SUCCESS. Die Invariante "neuester PROPOSAL_READY-Versuch ist die fuehrende
Quelle" bleibt gewahrt: Pro Lauf entstehen zwei Historieneintraege
(PROPOSAL_READY, dann SUCCESS). Bootstrap-E2E-Tests auf Single-Run-Semantik
angepasst; die "kein neuer KI-Aufruf bei vorhandenem PROPOSAL_READY"-Invariante
ist weiterhin im Application-Unit-Test abgedeckt.

Zusaetzlich log4j-core als Test-Scope-Abhaengigkeit im GUI-Modul ergaenzt,
damit die "Log4j2 could not find a logging implementation"-Warnung im
Testlauf nicht mehr erscheint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 17:26:21 +02:00
parent aaedc2d713
commit 8be1848ba9
11 changed files with 317 additions and 251 deletions
@@ -28,8 +28,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
*
* <h2>End-to-end invariants verified</h2>
* <ul>
* <li><strong>Happy-path to {@code SUCCESS}</strong>: two-run flow via {@code PROPOSAL_READY}
* intermediate state to a final {@code SUCCESS} with a target file on disk.</li>
* <li><strong>Happy-path to {@code SUCCESS}</strong>: a single run produces a historised
* {@code PROPOSAL_READY} attempt followed immediately by a {@code SUCCESS} attempt;
* 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>
@@ -44,9 +46,6 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
* <li><strong>Skip after {@code FAILED_FINAL}</strong>: a document whose status is
* {@code FAILED_FINAL} generates exactly one {@code SKIPPED_FINAL_FAILURE} attempt
* in the next run; the overall status and failure counters remain unchanged.</li>
* <li><strong>{@code PROPOSAL_READY} with later finalization</strong>: a document in
* {@code PROPOSAL_READY} state is finalized without an AI call in the next run,
* confirming the leading-proposal-attempt rule.</li>
* <li><strong>Target copy error with immediate within-run retry (success)</strong>: when the
* first copy attempt fails but the immediate within-run retry succeeds, the document is
* recorded as {@code SUCCESS} and no transient error counter is incremented.</li>
@@ -78,58 +77,49 @@ class BatchRunEndToEndTest {
// =========================================================================
/**
* Verifies the complete two-run happy-path:
* Verifies the complete single-run happy-path:
* <ol>
* <li>Run 1: AI stub returns valid proposal → document status becomes
* {@code PROPOSAL_READY}; no target file yet.</li>
* <li>Run 2: AI is NOT called again; target file is copied; document status
* becomes {@code SUCCESS}.</li>
* <li>AI stub returns a valid proposal; the coordinator first persists the
* {@code PROPOSAL_READY} attempt (authoritative source for the naming proposal)
* and the master record transitions through {@code PROPOSAL_READY}.</li>
* <li>Without leaving the same run the target-copy finalization executes and a second
* attempt with status {@code SUCCESS} is historised; the master record reaches
* {@code SUCCESS} and the target file is visible on disk.</li>
* </ol>
* This confirms the leading-proposal-attempt rule and the two-phase finalization.
* This confirms the leading-proposal-attempt rule as well as the per-document-per-run
* invariant of exactly two historised attempts (PROPOSAL_READY, then SUCCESS).
*/
@Test
void happyPath_twoRuns_reachesSuccess(@TempDir Path tempDir) throws Exception {
void happyPath_singleRun_reachesSuccess(@TempDir Path tempDir) throws Exception {
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
ctx.createSearchablePdf("rechnung.pdf", SAMPLE_PDF_TEXT);
Path pdfPath = ctx.sourceFolder().resolve("rechnung.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// --- Run 1: AI produces a naming proposal ---
BatchRunOutcome run1 = ctx.runBatch();
// --- Single run: AI produces proposal → PROPOSAL_READY historised → SUCCESS ---
BatchRunOutcome run = ctx.runBatch();
assertThat(run1).isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(ctx.aiStub.invocationCount()).isEqualTo(1);
DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record1.overallStatus()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(ctx.listTargetFiles()).isEmpty();
List<ProcessingAttempt> attempts1 = ctx.findAttempts(fp);
assertThat(attempts1).hasSize(1);
assertThat(attempts1.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
// --- Run 2: Finalization without AI call ---
ctx.aiStub.resetInvocationCount();
BatchRunOutcome run2 = ctx.runBatch();
assertThat(run2).isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(run).isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(ctx.aiStub.invocationCount())
.as("AI must not be called again when PROPOSAL_READY exists")
.isEqualTo(0);
.as("AI must be invoked exactly once within the single run")
.isEqualTo(1);
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(record2.lastSuccessInstant()).isNotNull();
assertThat(record2.lastTargetFileName()).isNotNull();
DocumentRecord record = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(record.lastSuccessInstant()).isNotNull();
assertThat(record.lastTargetFileName()).isNotNull();
List<String> targetFiles = ctx.listTargetFiles();
assertThat(targetFiles).hasSize(1);
assertThat(targetFiles.get(0)).endsWith(".pdf");
assertThat(Files.exists(ctx.targetFolder().resolve(targetFiles.get(0)))).isTrue();
List<ProcessingAttempt> attempts2 = ctx.findAttempts(fp);
assertThat(attempts2).hasSize(2);
assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.SUCCESS);
// Two historised attempts: first PROPOSAL_READY, then SUCCESS with final target filename
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(2);
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(attempts.get(1).finalTargetFileName()).isNotNull();
}
}
@@ -232,44 +222,47 @@ class BatchRunEndToEndTest {
/**
* Verifies the skip-after-success invariant:
* after a document reaches {@code SUCCESS} (via two runs), a third run records a
* after a document reaches {@code SUCCESS} in a single run (two historised attempts:
* {@code PROPOSAL_READY} followed by {@code SUCCESS}), a subsequent run records a
* {@code SKIPPED_ALREADY_PROCESSED} attempt without changing the overall status,
* failure counters, or the target file.
*/
@Test
void skipAfterSuccess_thirdRun_recordsSkip(@TempDir Path tempDir) throws Exception {
void skipAfterSuccess_secondRun_recordsSkip(@TempDir Path tempDir) throws Exception {
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// Reach SUCCESS via two runs
ctx.runBatch(); // → PROPOSAL_READY
ctx.runBatch(); // → SUCCESS
// Reach SUCCESS in a single run (PROPOSAL_READY → SUCCESS within the same run)
ctx.runBatch();
DocumentRecord successRecord = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(successRecord.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
String targetFileBefore = successRecord.lastTargetFileName();
// --- Run 3: should produce skip ---
// --- Subsequent run: should produce skip ---
ctx.aiStub.resetInvocationCount();
BatchRunOutcome run3 = ctx.runBatch();
BatchRunOutcome runSkip = ctx.runBatch();
assertThat(run3).isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(runSkip).isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(ctx.aiStub.invocationCount())
.as("AI must not be called for an already-successful document")
.isEqualTo(0);
DocumentRecord record3 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record3.overallStatus())
DocumentRecord recordAfterSkip = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(recordAfterSkip.overallStatus())
.as("Overall status must remain SUCCESS after a skip")
.isEqualTo(ProcessingStatus.SUCCESS);
assertThat(record3.lastTargetFileName())
assertThat(recordAfterSkip.lastTargetFileName())
.as("Target filename must not change after a skip")
.isEqualTo(targetFileBefore);
// Three historised attempts total: PROPOSAL_READY, SUCCESS, SKIPPED_ALREADY_PROCESSED
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(3);
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(attempts.get(2).status()).isEqualTo(ProcessingStatus.SKIPPED_ALREADY_PROCESSED);
assertThat(attempts.get(2).retryable()).isFalse();
@@ -322,66 +315,31 @@ class BatchRunEndToEndTest {
}
// =========================================================================
// Scenario 6: Existing PROPOSAL_READY with later finalization
// Scenario 6: Target copy error with immediate within-run retry
// =========================================================================
//
// Note: the former scenario "existing PROPOSAL_READY with later finalization" was
// removed because the coordinator now reaches SUCCESS within the same run as the AI
// call, which makes it impossible to produce a persisted PROPOSAL_READY state between
// two runs via the natural pipeline flow. The underlying invariant "no new AI call if
// a usable PROPOSAL_READY attempt already exists" is fully covered by the
// application-level unit test
// {@code DocumentProcessingCoordinatorTest#processDeferredOutcome_proposalReady_successfulCopy_persistsSuccessWithTargetFileName},
// which injects a lookup result with status PROPOSAL_READY and asserts that the AI
// pipeline is not invoked.
/**
* Verifies the leading-proposal-attempt rule in isolation:
* <ol>
* <li>Run 1: AI produces a naming proposal → document status is {@code PROPOSAL_READY}.</li>
* <li>Run 2: AI stub is reset to technical failure; the coordinator must still finalize
* the document to {@code SUCCESS} using the persisted proposal — without calling the AI.</li>
* </ol>
* This confirms that the second run never re-invokes the AI when a valid
* {@code PROPOSAL_READY} attempt already exists.
*/
@Test
void proposalReadyFinalization_noAiCallInSecondRun(@TempDir Path tempDir) throws Exception {
try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// --- Run 1: establish PROPOSAL_READY ---
ctx.runBatch();
DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record1.overallStatus()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(ctx.listTargetFiles()).isEmpty();
// --- Run 2: AI stub would fail if called, but must not be called ---
ctx.aiStub.configureTechnicalFailure();
ctx.aiStub.resetInvocationCount();
ctx.runBatch();
assertThat(ctx.aiStub.invocationCount())
.as("AI must not be invoked during PROPOSAL_READY finalization")
.isEqualTo(0);
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
List<String> targetFiles = ctx.listTargetFiles();
assertThat(targetFiles).hasSize(1);
assertThat(targetFiles.get(0)).endsWith(".pdf");
}
}
// =========================================================================
// Scenario 7: Target copy error with immediate within-run retry
// =========================================================================
/**
* Verifies the immediate within-run retry for target copy failures:
* <ol>
* <li>Run 1: AI produces {@code PROPOSAL_READY}.</li>
* <li>Run 2: The {@link TargetFileCopyPort} is overridden with a stub that fails on
* the first invocation but delegates to the real adapter on the second.
* The coordinator must detect the first failure, retry immediately within the
* same run, and record {@code SUCCESS} — without incrementing the transient
* error counter.</li>
* </ol>
* Verifies the immediate within-run retry for target copy failures within the
* single-run pipeline:
* <ul>
* <li>The {@link TargetFileCopyPort} is overridden with a stub that fails on the
* first invocation but delegates to the real adapter on the second.</li>
* <li>Within the same run the AI stub produces a naming proposal, the
* {@code PROPOSAL_READY} attempt is historised, the first copy attempt fails,
* and the immediate within-run retry succeeds.</li>
* <li>The document is recorded as {@code SUCCESS} — without incrementing the
* cross-run transient error counter.</li>
* </ul>
* The immediate retry does not count as a cross-run transient error.
*/
@Test
@@ -392,13 +350,7 @@ class BatchRunEndToEndTest {
Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// --- Run 1: produce PROPOSAL_READY ---
ctx.runBatch();
DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record1.overallStatus()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
// --- Run 2: first copy attempt fails, retry succeeds ---
// The copy port fails once and succeeds on the immediate within-run retry.
TargetFileCopyPort realAdapter =
new de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter(
ctx.targetFolder());
@@ -419,17 +371,20 @@ class BatchRunEndToEndTest {
ctx.runBatch();
assertThat(copyCallCount.get())
.as("Copy port must have been called twice (initial + retry)")
.as("Copy port must have been called twice (initial + retry) in the same run")
.isEqualTo(2);
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(record2.failureCounters().transientErrorCount())
DocumentRecord record = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(record.failureCounters().transientErrorCount())
.as("Immediate within-run retry must not increment the transient error counter")
.isEqualTo(0);
// Two historised attempts: PROPOSAL_READY then SUCCESS (the immediate within-run
// retry of the physical copy does not produce an extra historised attempt).
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(2);
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.SUCCESS);
List<String> targetFiles = ctx.listTargetFiles();
@@ -515,15 +470,16 @@ class BatchRunEndToEndTest {
// =========================================================================
/**
* Verifies the failure path of the immediate within-run retry mechanism:
* <ol>
* <li>Run 1: AI stub returns a valid proposal → {@code PROPOSAL_READY}.</li>
* <li>Run 2: The {@link TargetFileCopyPort} is overridden with a stub that fails
* on every call. The coordinator issues the initial copy attempt (failure),
* grants exactly one immediate retry (also failure), then classifies the
* result as a transient technical error and records {@code FAILED_RETRYABLE}
* with an incremented transient counter.</li>
* </ol>
* Verifies the failure path of the immediate within-run retry mechanism, within the
* single-run pipeline:
* <ul>
* <li>The {@link TargetFileCopyPort} is overridden with a stub that fails on every call.</li>
* <li>Within a single run the AI stub produces a valid proposal, the
* {@code PROPOSAL_READY} attempt is historised, the initial copy attempt fails,
* the immediate within-run retry also fails, and the combined result is
* classified as a transient technical error and persisted as
* {@code FAILED_RETRYABLE} with an incremented transient counter.</li>
* </ul>
* This confirms that the within-run retry does not suppress the error when both
* attempts fail, and that the transient counter is incremented exactly once.
*/
@@ -535,13 +491,7 @@ class BatchRunEndToEndTest {
Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// --- Run 1: establish PROPOSAL_READY ---
ctx.runBatch();
assertThat(ctx.findDocumentRecord(fp).orElseThrow().overallStatus())
.isEqualTo(ProcessingStatus.PROPOSAL_READY);
// --- Run 2: both copy attempts fail ---
// Both copy attempts (initial + immediate within-run retry) fail deterministically
ctx.setTargetFileCopyPortOverride(
(locator, resolvedFilename) ->
new TargetFileCopyTechnicalFailure(
@@ -558,8 +508,11 @@ class BatchRunEndToEndTest {
.as("The double copy failure must increment the transient counter exactly once")
.isEqualTo(1);
// Two historised attempts: first PROPOSAL_READY, then FAILED_RETRYABLE from the
// failed copy finalization (no SUCCESS, no extra attempt for the within-run retry).
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(2);
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(attempts.get(1).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
assertThat(attempts.get(1).retryable()).isTrue();
@@ -574,15 +527,13 @@ class BatchRunEndToEndTest {
/**
* Verifies the duplicate target filename suffix rule at end-to-end level:
* when two distinct source documents both resolve to the same base target name
* ({@code "2024-01-15 - Stromabrechnung.pdf"}) in the same finalization run, the
* second document written to the target folder must receive a {@code (1)} suffix.
* <ol>
* <li>Run 1: both PDFs are processed by the AI stub (same configured response) →
* both reach {@code PROPOSAL_READY}.</li>
* <li>Run 2: both are finalized in sequence; the first written claims the base name,
* the second receives {@code "2024-01-15 - Stromabrechnung(1).pdf"}.</li>
* </ol>
* Both documents reach {@code SUCCESS} and the target folder contains exactly two files.
* ({@code "2024-01-15 - Stromabrechnung.pdf"}) within the same single-run pipeline,
* the second document written to the target folder must receive a {@code (1)} suffix.
* <p>
* Both PDFs are processed sequentially in the same run: each one goes through
* AI-proposal → {@code PROPOSAL_READY} → target copy → {@code SUCCESS}. The first
* written claims the base name, the second receives
* {@code "2024-01-15 - Stromabrechnung(1).pdf"}.
*/
@Test
void twoDifferentDocuments_sameProposedName_secondGetsDuplicateSuffix(@TempDir Path tempDir)
@@ -598,16 +549,7 @@ class BatchRunEndToEndTest {
DocumentFingerprint fp1 = ctx.computeFingerprint(pdf1);
DocumentFingerprint fp2 = ctx.computeFingerprint(pdf2);
// --- Run 1: AI stub processes both PDFs → PROPOSAL_READY ---
ctx.runBatch();
assertThat(ctx.findDocumentRecord(fp1).orElseThrow().overallStatus())
.isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(ctx.findDocumentRecord(fp2).orElseThrow().overallStatus())
.isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(ctx.listTargetFiles()).isEmpty();
// --- Run 2: both finalized; the second must receive the (1) suffix ---
// --- Single run: both PDFs are processed and copied within the same run ---
ctx.runBatch();
assertThat(ctx.findDocumentRecord(fp1).orElseThrow().overallStatus())
@@ -635,10 +577,10 @@ class BatchRunEndToEndTest {
/**
* Verifies that document-level failures do not cause a batch-level failure:
* <ol>
* <li>Run 1: a searchable PDF reaches {@code PROPOSAL_READY}; a blank PDF
* (no extractable text) reaches {@code FAILED_RETRYABLE}.
* {@link BatchRunOutcome#SUCCESS} is returned.</li>
* <li>Run 2: the searchable PDF is finalized to {@code SUCCESS};
* <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>
* <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>
* </ol>
@@ -664,12 +606,18 @@ class BatchRunEndToEndTest {
.as("Batch must complete with SUCCESS even when individual documents fail")
.isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(ctx.findDocumentRecord(fpGood).orElseThrow().overallStatus())
.isEqualTo(ProcessingStatus.PROPOSAL_READY);
.as("Searchable PDF must reach SUCCESS within the same single run")
.isEqualTo(ProcessingStatus.SUCCESS);
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow().overallStatus())
.isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow()
.failureCounters().contentErrorCount()).isEqualTo(1);
// The successfully processed document already has its target file on disk
assertThat(ctx.listTargetFiles())
.as("Target file of the successfully processed document must exist after run 1")
.hasSize(1);
// --- Run 2 ---
BatchRunOutcome run2 = ctx.runBatch();
@@ -679,7 +627,9 @@ class BatchRunEndToEndTest {
.isEqualTo(BatchRunOutcome.SUCCESS);
DocumentRecord goodRecord = ctx.findDocumentRecord(fpGood).orElseThrow();
assertThat(goodRecord.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(goodRecord.overallStatus())
.as("Already successful document must remain SUCCESS after being skipped")
.isEqualTo(ProcessingStatus.SUCCESS);
DocumentRecord blankRecord = ctx.findDocumentRecord(fpBlank).orElseThrow();
assertThat(blankRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
@@ -60,10 +60,12 @@ class ProviderIdentifierE2ETest {
* Regression proof: the OpenAI-compatible provider path still produces the correct
* end-to-end outcome after the multi-provider extension.
* <p>
* Runs the two-phase happy path (AI call → {@code PROPOSAL_READY} in run 1,
* file copy → {@code SUCCESS} in run 2) with the {@code openai-compatible} provider
* identifier and verifies the final state matches the expected success outcome.
* This is the canonical regression check for the existing OpenAI flow.
* Runs the single-run happy path (AI call produces the proposal, the
* {@code PROPOSAL_READY} attempt is historised, and the target-copy finalization
* transitions the document to {@code SUCCESS} within the same run) with the
* {@code openai-compatible} provider identifier and verifies the final state matches
* the expected success outcome. This is the canonical regression check for the
* existing OpenAI flow.
*/
@Test
void regressionExistingOpenAiSuiteGreen(@TempDir Path tempDir) throws Exception {
@@ -72,20 +74,12 @@ class ProviderIdentifierE2ETest {
Path pdfPath = ctx.sourceFolder().resolve("regression.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// Run 1: AI produces naming proposal
BatchRunOutcome run1 = ctx.runBatch();
assertThat(run1).isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(resolveRecord(ctx, fp).overallStatus())
.isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(ctx.listTargetFiles()).isEmpty();
// Run 2: Finalization without AI call
ctx.aiStub.resetInvocationCount();
BatchRunOutcome run2 = ctx.runBatch();
assertThat(run2).isEqualTo(BatchRunOutcome.SUCCESS);
// Single run: AI proposal + target copy within the same run → SUCCESS
BatchRunOutcome run = ctx.runBatch();
assertThat(run).isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(ctx.aiStub.invocationCount())
.as("Existing OpenAI path must not re-invoke AI when PROPOSAL_READY exists")
.isEqualTo(0);
.as("OpenAI-compatible path must invoke the AI exactly once per document")
.isEqualTo(1);
assertThat(resolveRecord(ctx, fp).overallStatus())
.isEqualTo(ProcessingStatus.SUCCESS);
assertThat(ctx.listTargetFiles()).hasSize(1);
@@ -99,7 +93,12 @@ class ProviderIdentifierE2ETest {
/**
* Verifies that a batch run using the {@code openai-compatible} provider identifier
* persists {@code "openai-compatible"} in the {@code ai_provider} field of the
* attempt history record.
* {@code PROPOSAL_READY} attempt (the attempt carrying the AI invocation).
* <p>
* A single successful run produces two historised attempts for the same document:
* first the {@code PROPOSAL_READY} attempt (AI result) and then the {@code SUCCESS}
* attempt (target copy). The provider identifier is associated with the AI-invoking
* attempt.
*/
@Test
void e2eOpenAiRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception {
@@ -110,10 +109,16 @@ class ProviderIdentifierE2ETest {
ctx.runBatch();
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(1);
assertThat(attempts).hasSize(2);
assertThat(attempts.get(0).status())
.as("First historised attempt must be the PROPOSAL_READY attempt carrying the AI result")
.isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(attempts.get(0).aiProvider())
.as("Attempt produced by openai-compatible run must carry 'openai-compatible' as provider")
.as("PROPOSAL_READY attempt from an openai-compatible run must carry 'openai-compatible' as provider")
.isEqualTo("openai-compatible");
assertThat(attempts.get(1).status())
.as("Second historised attempt must finalise the document as SUCCESS")
.isEqualTo(ProcessingStatus.SUCCESS);
}
}
@@ -123,10 +128,13 @@ class ProviderIdentifierE2ETest {
/**
* Verifies that a batch run using the {@code claude} provider identifier persists
* {@code "claude"} in the {@code ai_provider} field of the attempt history record.
* {@code "claude"} in the {@code ai_provider} field of the {@code PROPOSAL_READY}
* attempt (the attempt carrying the AI invocation).
* <p>
* The AI invocation itself is still handled by the configurable {@link StubAiInvocationPort};
* only the provider identifier string (written by the coordinator) is the subject of this test.
* A single successful run produces two historised attempts; the provider identifier is
* associated with the AI-invoking {@code PROPOSAL_READY} attempt.
*/
@Test
void e2eClaudeRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception {
@@ -137,10 +145,16 @@ class ProviderIdentifierE2ETest {
ctx.runBatch();
List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(1);
assertThat(attempts).hasSize(2);
assertThat(attempts.get(0).status())
.as("First historised attempt must be the PROPOSAL_READY attempt carrying the AI result")
.isEqualTo(ProcessingStatus.PROPOSAL_READY);
assertThat(attempts.get(0).aiProvider())
.as("Attempt produced by claude run must carry 'claude' as provider")
.as("PROPOSAL_READY attempt from a claude run must carry 'claude' as provider")
.isEqualTo("claude");
assertThat(attempts.get(1).status())
.as("Second historised attempt must finalise the document as SUCCESS")
.isEqualTo(ProcessingStatus.SUCCESS);
}
}