+ * The intermediate {@link ProcessingStatus#PROPOSAL_READY} state is always persisted + * as a historised attempt before finalization, preserving the invariant that the + * leading source for the naming proposal is the most recent {@code PROPOSAL_READY} + * attempt in the history. + * + * @return true if processing and persistence succeeded, false if a persistence failure occurred + */ private boolean processAndPersistNewDocument( SourceDocumentCandidate candidate, DocumentFingerprint fingerprint, @@ -725,14 +741,36 @@ public class DocumentProcessingCoordinator { Instant now = Instant.now(); ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome); DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now); - return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, + boolean persistedOk = persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, pipelineOutcome, txOps -> txOps.createDocumentRecord(newRecord)); + + // If the pipeline yielded a naming proposal and the intermediate state was persisted + // successfully, continue with the target-copy finalization within the same run. + // The freshly persisted PROPOSAL_READY attempt is the authoritative source for the + // finalization step — no new AI call is triggered. + if (persistedOk && outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY) { + return finalizeProposalReady(candidate, fingerprint, newRecord, context, attemptStart); + } + return persistedOk; } // ========================================================================= // Known processable document path (non-PROPOSAL_READY) // ========================================================================= + /** + * Processes a known document whose persisted status is not yet {@code PROPOSAL_READY}: + * persists the pipeline attempt and updated master record, then — if the pipeline + * produced a naming proposal — immediately continues with the target-copy finalization + * within the same run. + *
+ * The intermediate {@link ProcessingStatus#PROPOSAL_READY} state is always persisted + * as a historised attempt before finalization, preserving the invariant that the + * leading source for the naming proposal is the most recent {@code PROPOSAL_READY} + * attempt in the history. + * + * @return true if processing and persistence succeeded, false if a persistence failure occurred + */ private boolean processAndPersistKnownDocument( SourceDocumentCandidate candidate, DocumentFingerprint fingerprint, @@ -745,8 +783,17 @@ public class DocumentProcessingCoordinator { ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters()); DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now); - return persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, + boolean persistedOk = persistAttemptAndRecord(candidate, fingerprint, context, attemptStart, now, outcome, pipelineOutcome, txOps -> txOps.updateDocumentRecord(updatedRecord)); + + // If the pipeline yielded a naming proposal and the intermediate state was persisted + // successfully, continue with the target-copy finalization within the same run. + // The freshly persisted PROPOSAL_READY attempt is the authoritative source for the + // finalization step — no new AI call is triggered. + if (persistedOk && outcome.overallStatus() == ProcessingStatus.PROPOSAL_READY) { + return finalizeProposalReady(candidate, fingerprint, updatedRecord, context, attemptStart); + } + return persistedOk; } // ========================================================================= diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java index e9b2e54..49d5420 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingService.java @@ -91,7 +91,7 @@ public final class TargetFilenameBuildingService { *
- * The 20-character limit applies exclusively to the base title. A duplicate-avoidance + * The 60-character limit applies exclusively to the base title. A duplicate-avoidance * suffix (e.g., {@code (1)}) may be appended by the target folder adapter after this - * method returns and is not counted against the 20 characters. + * method returns and is not counted against the 60 characters. * * @param proposalAttempt the leading {@code PROPOSAL_READY} attempt; must not be null * @return a {@link BaseFilenameReady} with the complete filename, or an @@ -126,9 +126,9 @@ public final class TargetFilenameBuildingService { "Leading PROPOSAL_READY attempt has no validated title"); } - if (title.length() > 20) { + if (title.length() > 60) { return new InconsistentProposalState( - "Leading PROPOSAL_READY attempt has title exceeding 20 characters: '" + "Leading PROPOSAL_READY attempt has title exceeding 60 characters: '" + title + "'"); } diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java index ba9e0f4..1dedf23 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiNamingServiceTest.java @@ -172,9 +172,10 @@ class AiNamingServiceTest { void invoke_aiResponseTitleTooLong_returnsAiFunctionalFailure() { when(promptPort.loadPrompt()).thenReturn( new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt")); - // 21-char title: "TitleThatIsTooLongXXX" + // 61-char title: exceeds the 60-character limit + String longTitle = "1234567890123456789012345678901234567890123456789012345678901"; when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn( - successWith("{\"title\":\"TitleThatIsTooLongXXX\",\"reasoning\":\"Too long\",\"date\":\"2026-01-15\"}")); + successWith("{\"title\":\"" + longTitle + "\",\"reasoning\":\"Too long\",\"date\":\"2026-01-15\"}")); DocumentProcessingOutcome result = service.invoke(preCheckPassed); diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidatorTest.java index dc03287..5271daa 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidatorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidatorTest.java @@ -115,15 +115,15 @@ class AiResponseValidatorTest { // ------------------------------------------------------------------------- @Test - void validate_title21Chars_returnsInvalid() { - String title = "1234567890123456789A1"; // 21 chars + void validate_titleOver60Chars_returnsInvalid() { + String title = "1234567890123456789012345678901234567890123456789012345678901"; // 61 chars ParsedAiResponse parsed = ParsedAiResponse.of(title, "reasoning", null); AiResponseValidator.AiValidationResult result = validator.validate(parsed); assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class); assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage()) - .contains("20"); + .contains("60"); } @Test diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java index 69c533e..13bd857 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinatorTest.java @@ -109,29 +109,45 @@ class DocumentProcessingCoordinatorTest { // ------------------------------------------------------------------------- @Test - void process_newDocument_namingProposalReady_persistsProposalReadyStatus() { + void process_newDocument_namingProposalReady_persistsProposalReadyThenContinuesToSuccess() { recordRepo.setLookupResult(new DocumentUnknown()); DocumentProcessingOutcome outcome = buildNamingProposalOutcome(); processor.process(candidate, fingerprint, outcome, context, attemptStart); - // One attempt written - assertEquals(1, attemptRepo.savedAttempts.size()); - ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); - assertEquals(ProcessingStatus.PROPOSAL_READY, attempt.status()); - assertFalse(attempt.retryable()); - assertNull(attempt.failureClass()); - assertNull(attempt.failureMessage()); + // Two attempts written: first PROPOSAL_READY (pipeline stage), then SUCCESS (target-copy stage). + // The intermediate PROPOSAL_READY attempt is the authoritative source for finalization. + assertEquals(2, attemptRepo.savedAttempts.size(), + "PROPOSAL_READY must be historised before the SUCCESS attempt in the same run"); + ProcessingAttempt proposalAttempt = attemptRepo.savedAttempts.get(0); + assertEquals(ProcessingStatus.PROPOSAL_READY, proposalAttempt.status()); + assertFalse(proposalAttempt.retryable()); + assertNull(proposalAttempt.failureClass()); + assertNull(proposalAttempt.failureMessage()); - // One master record created + ProcessingAttempt successAttempt = attemptRepo.savedAttempts.get(1); + assertEquals(ProcessingStatus.SUCCESS, successAttempt.status()); + assertNotNull(successAttempt.finalTargetFileName(), + "SUCCESS attempt must carry the final target filename"); + + // One master record created for the new document; one update from the finalization stage. assertEquals(1, recordRepo.createdRecords.size()); - DocumentRecord record = recordRepo.createdRecords.get(0); - assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus()); - assertEquals(0, record.failureCounters().contentErrorCount()); - assertEquals(0, record.failureCounters().transientErrorCount()); - // lastSuccessInstant is null after AI naming proposal; it is set only after the target-copy stage - assertNull(record.lastSuccessInstant()); - assertNull(record.lastFailureInstant()); + DocumentRecord createdRecord = recordRepo.createdRecords.get(0); + assertEquals(ProcessingStatus.PROPOSAL_READY, createdRecord.overallStatus()); + assertEquals(0, createdRecord.failureCounters().contentErrorCount()); + assertEquals(0, createdRecord.failureCounters().transientErrorCount()); + // The initial PROPOSAL_READY record has no success/failure timestamps yet. + assertNull(createdRecord.lastSuccessInstant()); + assertNull(createdRecord.lastFailureInstant()); + + assertEquals(1, recordRepo.updatedRecords.size(), + "Master record must be updated to SUCCESS after the target-copy stage"); + DocumentRecord finalRecord = recordRepo.updatedRecords.get(0); + assertEquals(ProcessingStatus.SUCCESS, finalRecord.overallStatus()); + assertNotNull(finalRecord.lastSuccessInstant(), + "lastSuccessInstant must be set after successful target copy"); + assertNotNull(finalRecord.lastTargetFileName(), + "Master record must carry the final target filename after SUCCESS"); } @Test @@ -273,7 +289,7 @@ class DocumentProcessingCoordinatorTest { } @Test - void process_knownDocument_namingProposalReady_persistsProposalReadyStatus() { + void process_knownDocument_namingProposalReady_persistsProposalReadyThenContinuesToSuccess() { DocumentRecord existingRecord = buildRecord( ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(0, 1)); @@ -283,14 +299,22 @@ class DocumentProcessingCoordinatorTest { processor.process(candidate, fingerprint, outcome, context, attemptStart); - assertEquals(1, recordRepo.updatedRecords.size()); - DocumentRecord record = recordRepo.updatedRecords.get(0); - assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus()); + // Two record updates: first to PROPOSAL_READY (pipeline stage), then to SUCCESS (target-copy stage). + assertEquals(2, recordRepo.updatedRecords.size(), + "PROPOSAL_READY update must be followed by a SUCCESS update in the same run"); + DocumentRecord intermediate = recordRepo.updatedRecords.get(0); + assertEquals(ProcessingStatus.PROPOSAL_READY, intermediate.overallStatus()); // Counters unchanged on naming proposal success - assertEquals(0, record.failureCounters().contentErrorCount()); - assertEquals(1, record.failureCounters().transientErrorCount()); - // lastSuccessInstant is null after AI naming proposal; it is set only after the target-copy stage - assertNull(record.lastSuccessInstant()); + assertEquals(0, intermediate.failureCounters().contentErrorCount()); + assertEquals(1, intermediate.failureCounters().transientErrorCount()); + // lastSuccessInstant is still null after the intermediate PROPOSAL_READY write. + assertNull(intermediate.lastSuccessInstant()); + + DocumentRecord finalRecord = recordRepo.updatedRecords.get(1); + assertEquals(ProcessingStatus.SUCCESS, finalRecord.overallStatus()); + assertNotNull(finalRecord.lastSuccessInstant(), + "lastSuccessInstant must be set after the successful target copy"); + assertNotNull(finalRecord.lastTargetFileName()); } // ------------------------------------------------------------------------- @@ -587,15 +611,19 @@ class DocumentProcessingCoordinatorTest { @Test void process_newDocument_namingProposalReady_failureClassAndMessageAreNull() { - // Prüft, dass bei PROPOSAL_READY failureClass und failureMessage null sind + // Prüft, dass der im selben Lauf persistierte PROPOSAL_READY-Versuch ohne + // failureClass/failureMessage geschrieben wird. recordRepo.setLookupResult(new DocumentUnknown()); DocumentProcessingOutcome outcome = buildNamingProposalOutcome(); processor.process(candidate, fingerprint, outcome, context, attemptStart); - ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); - assertNull(attempt.failureClass(), "Bei PROPOSAL_READY muss failureClass null sein"); - assertNull(attempt.failureMessage(), "Bei PROPOSAL_READY muss failureMessage null sein"); + ProcessingAttempt proposalAttempt = attemptRepo.savedAttempts.stream() + .filter(a -> a.status() == ProcessingStatus.PROPOSAL_READY) + .findFirst() + .orElseThrow(() -> new AssertionError("PROPOSAL_READY attempt must be persisted")); + assertNull(proposalAttempt.failureClass(), "Bei PROPOSAL_READY muss failureClass null sein"); + assertNull(proposalAttempt.failureMessage(), "Bei PROPOSAL_READY muss failureMessage null sein"); } // ------------------------------------------------------------------------- @@ -603,9 +631,9 @@ class DocumentProcessingCoordinatorTest { // ------------------------------------------------------------------------- @Test - void process_knownDocument_namingProposalReady_lastSuccessInstantNullAndLastFailureInstantFromPreviousRecord() { - // Prüft, dass bei PROPOSAL_READY am known-Dokument lastSuccessInstant null bleibt - // (wird erst nach der Zielkopie gesetzt) und lastFailureInstant aus dem Vorgänger übernommen wird + void process_knownDocument_namingProposalReady_intermediateRecordKeepsPreviousFailureAndHasNoSuccess() { + // Prüft den persistierten Zwischenzustand (PROPOSAL_READY-Update) im selben Lauf: + // lastSuccessInstant bleibt null bis zur Zielkopie; lastFailureInstant aus dem Vorgänger bleibt erhalten. Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z"); DocumentRecord existingRecord = new DocumentRecord( fingerprint, @@ -625,11 +653,13 @@ class DocumentProcessingCoordinatorTest { processor.process(candidate, fingerprint, outcome, context, attemptStart); - DocumentRecord updated = recordRepo.updatedRecords.get(0); - assertNull(updated.lastSuccessInstant(), - "lastSuccessInstant muss nach PROPOSAL_READY null bleiben (wird erst nach der Zielkopie gesetzt)"); - assertEquals(previousFailureInstant, updated.lastFailureInstant(), - "lastFailureInstant muss bei PROPOSAL_READY den Vorgänger-Wert beibehalten"); + // Der erste Update-Record ist der Zwischenzustand (PROPOSAL_READY) vor der Finalisierung. + DocumentRecord intermediate = recordRepo.updatedRecords.get(0); + assertEquals(ProcessingStatus.PROPOSAL_READY, intermediate.overallStatus()); + assertNull(intermediate.lastSuccessInstant(), + "lastSuccessInstant muss im PROPOSAL_READY-Zwischenstand null bleiben"); + assertEquals(previousFailureInstant, intermediate.lastFailureInstant(), + "lastFailureInstant muss im PROPOSAL_READY-Zwischenstand den Vorgänger-Wert beibehalten"); } @Test @@ -914,18 +944,18 @@ class DocumentProcessingCoordinatorTest { } @Test - void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds20Chars_persistsTransientError() { + void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds60Chars_persistsTransientError() { DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero()); recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); - // Title of 21 characters violates the 20-char base-title rule — inconsistent persistence state + // Title of 61 characters violates the 60-char base-title rule — inconsistent persistence state ProcessingAttempt badProposal = new ProcessingAttempt( fingerprint, context.runId(), 1, Instant.now(), Instant.now(), ProcessingStatus.PROPOSAL_READY, null, null, false, null, "model", "prompt", 1, 100, "{}", "reason", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, - "A".repeat(21), null); + "A".repeat(61), null); attemptRepo.savedAttempts.add(badProposal); boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java index 8649559..ef8d763 100644 --- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java +++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/service/TargetFilenameBuildingServiceTest.java @@ -20,7 +20,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId; /** * Unit tests for {@link TargetFilenameBuildingService}. *
- * Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 20-character + * Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 60-character * base-title rule, the fachliche Titelregel (only letters, digits, and spaces), * Windows-compatibility character removal, and the detection of inconsistent persistence * states. @@ -100,8 +100,8 @@ class TargetFilenameBuildingServiceTest { } @Test - void buildBaseFilename_titleExactly20Chars_isAccepted() { - String title = "A".repeat(20); // exactly 20 characters + void buildBaseFilename_titleExactly60Chars_isAccepted() { + String title = "A".repeat(60); // exactly 60 characters ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title); BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); @@ -110,13 +110,13 @@ class TargetFilenameBuildingServiceTest { } // ------------------------------------------------------------------------- - // 20-character rule applies only to base title; format structure is separate + // 60-character rule applies only to base title; format structure is separate // ------------------------------------------------------------------------- @Test void buildBaseFilename_format_separatorAndExtensionAreNotCountedAgainstTitle() { // A 20-char title produces "YYYY-MM-DD - <20chars>.pdf" — total > 20 chars, which is fine - String title = "Stromabrechnung 2026"; // 20 chars + String title = "Stromabrechnung 2026"; // 20 chars (well within 60-char limit) ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title); BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); @@ -170,19 +170,19 @@ class TargetFilenameBuildingServiceTest { } // ------------------------------------------------------------------------- - // InconsistentProposalState – title exceeds 20 characters + // InconsistentProposalState – title exceeds 60 characters // ------------------------------------------------------------------------- @Test - void buildBaseFilename_titleExceeds20Chars_returnsInconsistentProposalState() { - String title = "A".repeat(21); // 21 characters + void buildBaseFilename_titleExceeds60Chars_returnsInconsistentProposalState() { + String title = "A".repeat(61); // 61 characters ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title); BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); assertThat(result).isInstanceOf(InconsistentProposalState.class); assertThat(((InconsistentProposalState) result).reason()) - .contains("exceeding 20 characters"); + .contains("exceeding 60 characters"); } // ------------------------------------------------------------------------- @@ -305,10 +305,10 @@ class TargetFilenameBuildingServiceTest { } @Test - void buildBaseFilename_twentyCharacterRuleUnaffectedByWindowsCompatibility() { - // The 20-character rule applies to the base title only. + void buildBaseFilename_sixtyCharacterRuleUnaffectedByWindowsCompatibility() { + // The 60-character rule applies to the base title only. // Windows-compatibility cleaning does not change the length counting mechanism. - String title = "Stromabrechnung 2026"; // exactly 20 characters + String title = "Stromabrechnung 2026"; // 20 characters (within 60-char limit) ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title); BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); 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 1e09381..a58d4c7 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 @@ -28,8 +28,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; * *
+ * 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: *
- * 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). + *
+ * 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
* 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