From 8be1848ba93c1bdbe0c4751e761ccceca99c60ed Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 21 Apr 2026 17:26:21 +0200 Subject: [PATCH] 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) --- config/prompts/template.txt | 11 + pdf-umbenenner-adapter-in-gui/pom.xml | 13 + .../service/AiResponseValidator.java | 6 +- .../DocumentProcessingCoordinator.java | 55 +++- .../TargetFilenameBuildingService.java | 12 +- .../service/AiNamingServiceTest.java | 5 +- .../service/AiResponseValidatorTest.java | 6 +- .../DocumentProcessingCoordinatorTest.java | 108 ++++--- .../TargetFilenameBuildingServiceTest.java | 24 +- .../bootstrap/e2e/BatchRunEndToEndTest.java | 268 +++++++----------- .../e2e/ProviderIdentifierE2ETest.java | 60 ++-- 11 files changed, 317 insertions(+), 251 deletions(-) diff --git a/config/prompts/template.txt b/config/prompts/template.txt index 167c826..097d751 100644 --- a/config/prompts/template.txt +++ b/config/prompts/template.txt @@ -20,3 +20,14 @@ Titelregeln: - Eindeutig und verständlich, nicht generisch Wenn das Dokument nicht eindeutig interpretierbar ist, beschreibe dies im Reasoning. + +**Ausgabeformat: Ausschließlich reines JSON-Objekt** + +Antworte nur mit einem JSON-Objekt nach folgendem Schema: +- Keine Präambel, keine Erklärungen, keine Markdown-Codeblöcke +- `title` (erforderlich): Der ermittelte deutsche Titel +- `reasoning` (erforderlich): Begründung der Entscheidung +- `date` (optional): Das ermittelte Datum im Format YYYY-MM-DD; auslassen, falls kein belastbares Datum ableitbar ist + +Beispiel: +{"title":"Stromabrechnung","reasoning":"Das Rechnungsdatum 2026-02-11 ist eindeutig erkennbar.","date":"2026-02-11"} diff --git a/pdf-umbenenner-adapter-in-gui/pom.xml b/pdf-umbenenner-adapter-in-gui/pom.xml index 392132f..a34f071 100644 --- a/pdf-umbenenner-adapter-in-gui/pom.xml +++ b/pdf-umbenenner-adapter-in-gui/pom.xml @@ -47,6 +47,19 @@ + + + org.apache.logging.log4j + log4j-core + test + org.junit.jupiter junit-jupiter diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java index 34b8821..e6c1b31 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/AiResponseValidator.java @@ -23,7 +23,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse; * *

Title rules (objective)

*
    - *
  • Base title must not exceed 20 characters.
  • + *
  • Base title must not exceed 60 characters.
  • *
  • Title must not contain characters other than letters, digits, and space * (Umlauts and ß are permitted).
  • *
  • Title must not be a generic placeholder (e.g., "Dokument", "Datei", "Scan", @@ -85,9 +85,9 @@ public final class AiResponseValidator { // --- Title validation --- String title = parsed.title().trim(); - if (title.length() > 20) { + if (title.length() > 60) { return AiValidationResult.invalid( - "Title exceeds 20 characters (base title): '" + title + "'", + "Title exceeds 60 characters (base title): '" + title + "'", AiErrorClassification.FUNCTIONAL); } diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java index a4e4b11..d04b896 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/service/DocumentProcessingCoordinator.java @@ -63,7 +63,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError; * log generated target filename at INFO with fingerprint, * write the copy, persist SUCCESS or FAILED_RETRYABLE.
  • *
  • Otherwise execute the pipeline (extraction + pre-checks + AI naming) and map - * the result into status, counters, and retryable flag.
  • + * the result into status, counters, and retryable flag. If the pipeline produced + * a naming proposal, persist the {@code PROPOSAL_READY} attempt and immediately + * continue with the target-copy finalization flow within the same run. *
  • Log retry decision at INFO with fingerprint and error classification: * FAILED_RETRYABLE (will retry in a later scheduler run) or * FAILED_FINAL (retry budget exhausted, no further processing).
  • @@ -267,7 +269,9 @@ public class DocumentProcessingCoordinator { *
  • If the status is {@code PROPOSAL_READY} → execute the target-copy * finalization without invoking the AI pipeline again.
  • *
  • Otherwise execute the pipeline (extraction + pre-checks + AI naming) and - * persist the outcome.
  • + * persist the outcome. If the pipeline produced a naming proposal, the + * intermediate {@code PROPOSAL_READY} attempt is persisted and the + * target-copy finalization is executed within the same run. * * * @param candidate the source document candidate; must not be null @@ -715,6 +719,18 @@ public class DocumentProcessingCoordinator { // New document path // ========================================================================= + /** + * Processes a brand-new document: persists the pipeline attempt and 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 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 { *

      *
    • Resolved date must be non-null.
    • *
    • Validated title must be non-null and non-blank.
    • - *
    • Validated title must not exceed 20 characters (before Windows cleaning).
    • + *
    • Validated title must not exceed 60 characters (before Windows cleaning).
    • *
    • After Windows-character cleaning, title must contain only letters, digits, and spaces.
    • *
    * If any rule is violated, the state is treated as an @@ -100,11 +100,11 @@ public final class TargetFilenameBuildingService { * Windows compatibility: Windows-incompatible characters * (e.g., {@code < > : " / \ | ? *}) are removed from the title before final validation. * This ensures the resulting filename can be created on Windows systems. - * The 20-character rule is applied to the original title before cleaning. + * The 60-character rule is applied to the original title before cleaning. *

    - * 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; * *

    End-to-end invariants verified

    *
      - *
    • Happy-path to {@code SUCCESS}: two-run flow via {@code PROPOSAL_READY} - * intermediate state to a final {@code SUCCESS} with a target file on disk.
    • + *
    • Happy-path to {@code SUCCESS}: 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.
    • *
    • 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.
    • @@ -44,9 +46,6 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus; *
    • Skip after {@code FAILED_FINAL}: 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.
    • - *
    • {@code PROPOSAL_READY} with later finalization: a document in - * {@code PROPOSAL_READY} state is finalized without an AI call in the next run, - * confirming the leading-proposal-attempt rule.
    • *
    • Target copy error with immediate within-run retry (success): 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.
    • @@ -78,58 +77,49 @@ class BatchRunEndToEndTest { // ========================================================================= /** - * Verifies the complete two-run happy-path: + * Verifies the complete single-run happy-path: *
        - *
      1. Run 1: AI stub returns valid proposal → document status becomes - * {@code PROPOSAL_READY}; no target file yet.
      2. - *
      3. Run 2: AI is NOT called again; target file is copied; document status - * becomes {@code SUCCESS}.
      4. + *
      5. 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}.
      6. + *
      7. 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.
      8. *
      - * 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 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 targetFiles = ctx.listTargetFiles(); assertThat(targetFiles).hasSize(1); assertThat(targetFiles.get(0)).endsWith(".pdf"); assertThat(Files.exists(ctx.targetFolder().resolve(targetFiles.get(0)))).isTrue(); - List 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 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 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: - *
        - *
      1. Run 1: AI produces a naming proposal → document status is {@code PROPOSAL_READY}.
      2. - *
      3. 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.
      4. - *
      - * 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 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: - *
        - *
      1. Run 1: AI produces {@code PROPOSAL_READY}.
      2. - *
      3. 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.
      4. - *
      + * Verifies the immediate within-run retry for target copy failures within the + * single-run pipeline: + *
        + *
      • The {@link TargetFileCopyPort} is overridden with a stub that fails on the + * first invocation but delegates to the real adapter on the second.
      • + *
      • 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.
      • + *
      • The document is recorded as {@code SUCCESS} — without incrementing the + * cross-run transient error counter.
      • + *
      * 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 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 targetFiles = ctx.listTargetFiles(); @@ -515,15 +470,16 @@ class BatchRunEndToEndTest { // ========================================================================= /** - * Verifies the failure path of the immediate within-run retry mechanism: - *
        - *
      1. Run 1: AI stub returns a valid proposal → {@code PROPOSAL_READY}.
      2. - *
      3. 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.
      4. - *
      + * Verifies the failure path of the immediate within-run retry mechanism, within the + * single-run pipeline: + *
        + *
      • The {@link TargetFileCopyPort} is overridden with a stub that fails on every call.
      • + *
      • 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.
      • + *
      * 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 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. - *
        - *
      1. Run 1: both PDFs are processed by the AI stub (same configured response) → - * both reach {@code PROPOSAL_READY}.
      2. - *
      3. Run 2: both are finalized in sequence; the first written claims the base name, - * the second receives {@code "2024-01-15 - Stromabrechnung(1).pdf"}.
      4. - *
      - * 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. + *

      + * 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: *

        - *
      1. Run 1: a searchable PDF reaches {@code PROPOSAL_READY}; a blank PDF - * (no extractable text) reaches {@code FAILED_RETRYABLE}. - * {@link BatchRunOutcome#SUCCESS} is returned.
      2. - *
      3. Run 2: the searchable PDF is finalized to {@code SUCCESS}; + *
      4. 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.
      5. + *
      6. 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.
      7. *
      @@ -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); diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java index b2899aa..dc14de4 100644 --- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java +++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/e2e/ProviderIdentifierE2ETest.java @@ -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. *

      - * 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 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). *

      * 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 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); } }