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
+11
View File
@@ -20,3 +20,14 @@ Titelregeln:
- Eindeutig und verständlich, nicht generisch - Eindeutig und verständlich, nicht generisch
Wenn das Dokument nicht eindeutig interpretierbar ist, beschreibe dies im Reasoning. 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"}
+13
View File
@@ -47,6 +47,19 @@
</dependency> </dependency>
<!-- Test dependencies --> <!-- Test dependencies -->
<!--
log4j-core on the test classpath provides the logging implementation for
tests that instantiate production classes using LogManager.getLogger.
Without it, Log4j2 falls back to SimpleLogger during test execution and
prints "Log4j2 could not find a logging implementation" at test start.
The production classpath is unaffected; log4j-core is supplied by the
bootstrap module in the shaded runtime JAR.
-->
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
@@ -23,7 +23,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
* *
* <h3>Title rules (objective)</h3> * <h3>Title rules (objective)</h3>
* <ul> * <ul>
* <li>Base title must not exceed 20 characters.</li> * <li>Base title must not exceed 60 characters.</li>
* <li>Title must not contain characters other than letters, digits, and space * <li>Title must not contain characters other than letters, digits, and space
* (Umlauts and ß are permitted).</li> * (Umlauts and ß are permitted).</li>
* <li>Title must not be a generic placeholder (e.g., "Dokument", "Datei", "Scan", * <li>Title must not be a generic placeholder (e.g., "Dokument", "Datei", "Scan",
@@ -85,9 +85,9 @@ public final class AiResponseValidator {
// --- Title validation --- // --- Title validation ---
String title = parsed.title().trim(); String title = parsed.title().trim();
if (title.length() > 20) { if (title.length() > 60) {
return AiValidationResult.invalid( return AiValidationResult.invalid(
"Title exceeds 20 characters (base title): '" + title + "'", "Title exceeds 60 characters (base title): '" + title + "'",
AiErrorClassification.FUNCTIONAL); AiErrorClassification.FUNCTIONAL);
} }
@@ -63,7 +63,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.TechnicalDocumentError;
* <strong>log generated target filename at INFO with fingerprint</strong>, * <strong>log generated target filename at INFO with fingerprint</strong>,
* write the copy, persist SUCCESS or FAILED_RETRYABLE.</li> * write the copy, persist SUCCESS or FAILED_RETRYABLE.</li>
* <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and map * <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and map
* the result into status, counters, and retryable flag.</li> * 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.</li>
* <li><strong>Log retry decision at INFO with fingerprint and error classification</strong>: * <li><strong>Log retry decision at INFO with fingerprint and error classification</strong>:
* FAILED_RETRYABLE (will retry in a later scheduler run) or * FAILED_RETRYABLE (will retry in a later scheduler run) or
* FAILED_FINAL (retry budget exhausted, no further processing).</li> * FAILED_FINAL (retry budget exhausted, no further processing).</li>
@@ -267,7 +269,9 @@ public class DocumentProcessingCoordinator {
* <li>If the status is {@code PROPOSAL_READY} → execute the target-copy * <li>If the status is {@code PROPOSAL_READY} → execute the target-copy
* finalization without invoking the AI pipeline again.</li> * finalization without invoking the AI pipeline again.</li>
* <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and * <li>Otherwise execute the pipeline (extraction + pre-checks + AI naming) and
* persist the outcome.</li> * 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.</li>
* </ol> * </ol>
* *
* @param candidate the source document candidate; must not be null * @param candidate the source document candidate; must not be null
@@ -715,6 +719,18 @@ public class DocumentProcessingCoordinator {
// New document path // 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.
* <p>
* 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( private boolean processAndPersistNewDocument(
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint, DocumentFingerprint fingerprint,
@@ -725,14 +741,36 @@ public class DocumentProcessingCoordinator {
Instant now = Instant.now(); Instant now = Instant.now();
ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome); ProcessingOutcomeTransition.ProcessingOutcome outcome = mapOutcomeForNewDocument(pipelineOutcome);
DocumentRecord newRecord = buildNewDocumentRecord(fingerprint, candidate, outcome, now); 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)); 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) // 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.
* <p>
* 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( private boolean processAndPersistKnownDocument(
SourceDocumentCandidate candidate, SourceDocumentCandidate candidate,
DocumentFingerprint fingerprint, DocumentFingerprint fingerprint,
@@ -745,8 +783,17 @@ public class DocumentProcessingCoordinator {
ProcessingOutcomeTransition.ProcessingOutcome outcome = ProcessingOutcomeTransition.ProcessingOutcome outcome =
mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters()); mapOutcomeForKnownDocument(pipelineOutcome, existingRecord.failureCounters());
DocumentRecord updatedRecord = buildUpdatedDocumentRecord(existingRecord, candidate, outcome, now); 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)); 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;
} }
// ========================================================================= // =========================================================================
@@ -91,7 +91,7 @@ public final class TargetFilenameBuildingService {
* <ul> * <ul>
* <li>Resolved date must be non-null.</li> * <li>Resolved date must be non-null.</li>
* <li>Validated title must be non-null and non-blank.</li> * <li>Validated title must be non-null and non-blank.</li>
* <li>Validated title must not exceed 20 characters (before Windows cleaning).</li> * <li>Validated title must not exceed 60 characters (before Windows cleaning).</li>
* <li>After Windows-character cleaning, title must contain only letters, digits, and spaces.</li> * <li>After Windows-character cleaning, title must contain only letters, digits, and spaces.</li>
* </ul> * </ul>
* If any rule is violated, the state is treated as an * If any rule is violated, the state is treated as an
@@ -100,11 +100,11 @@ public final class TargetFilenameBuildingService {
* Windows compatibility: Windows-incompatible characters * Windows compatibility: Windows-incompatible characters
* (e.g., {@code < > : " / \ | ? *}) are removed from the title before final validation. * (e.g., {@code < > : " / \ | ? *}) are removed from the title before final validation.
* This ensures the resulting filename can be created on Windows systems. * 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.
* <p> * <p>
* 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 * 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 * @param proposalAttempt the leading {@code PROPOSAL_READY} attempt; must not be null
* @return a {@link BaseFilenameReady} with the complete filename, or an * @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"); "Leading PROPOSAL_READY attempt has no validated title");
} }
if (title.length() > 20) { if (title.length() > 60) {
return new InconsistentProposalState( return new InconsistentProposalState(
"Leading PROPOSAL_READY attempt has title exceeding 20 characters: '" "Leading PROPOSAL_READY attempt has title exceeding 60 characters: '"
+ title + "'"); + title + "'");
} }
@@ -172,9 +172,10 @@ class AiNamingServiceTest {
void invoke_aiResponseTitleTooLong_returnsAiFunctionalFailure() { void invoke_aiResponseTitleTooLong_returnsAiFunctionalFailure() {
when(promptPort.loadPrompt()).thenReturn( when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt")); 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( 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); DocumentProcessingOutcome result = service.invoke(preCheckPassed);
@@ -115,15 +115,15 @@ class AiResponseValidatorTest {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Test @Test
void validate_title21Chars_returnsInvalid() { void validate_titleOver60Chars_returnsInvalid() {
String title = "1234567890123456789A1"; // 21 chars String title = "1234567890123456789012345678901234567890123456789012345678901"; // 61 chars
ParsedAiResponse parsed = ParsedAiResponse.of(title, "reasoning", null); ParsedAiResponse parsed = ParsedAiResponse.of(title, "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed); AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class); assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage()) assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.contains("20"); .contains("60");
} }
@Test @Test
@@ -109,29 +109,45 @@ class DocumentProcessingCoordinatorTest {
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Test @Test
void process_newDocument_namingProposalReady_persistsProposalReadyStatus() { void process_newDocument_namingProposalReady_persistsProposalReadyThenContinuesToSuccess() {
recordRepo.setLookupResult(new DocumentUnknown()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = buildNamingProposalOutcome(); DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
processor.process(candidate, fingerprint, outcome, context, attemptStart); processor.process(candidate, fingerprint, outcome, context, attemptStart);
// One attempt written // Two attempts written: first PROPOSAL_READY (pipeline stage), then SUCCESS (target-copy stage).
assertEquals(1, attemptRepo.savedAttempts.size()); // The intermediate PROPOSAL_READY attempt is the authoritative source for finalization.
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); assertEquals(2, attemptRepo.savedAttempts.size(),
assertEquals(ProcessingStatus.PROPOSAL_READY, attempt.status()); "PROPOSAL_READY must be historised before the SUCCESS attempt in the same run");
assertFalse(attempt.retryable()); ProcessingAttempt proposalAttempt = attemptRepo.savedAttempts.get(0);
assertNull(attempt.failureClass()); assertEquals(ProcessingStatus.PROPOSAL_READY, proposalAttempt.status());
assertNull(attempt.failureMessage()); 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()); assertEquals(1, recordRepo.createdRecords.size());
DocumentRecord record = recordRepo.createdRecords.get(0); DocumentRecord createdRecord = recordRepo.createdRecords.get(0);
assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus()); assertEquals(ProcessingStatus.PROPOSAL_READY, createdRecord.overallStatus());
assertEquals(0, record.failureCounters().contentErrorCount()); assertEquals(0, createdRecord.failureCounters().contentErrorCount());
assertEquals(0, record.failureCounters().transientErrorCount()); assertEquals(0, createdRecord.failureCounters().transientErrorCount());
// lastSuccessInstant is null after AI naming proposal; it is set only after the target-copy stage // The initial PROPOSAL_READY record has no success/failure timestamps yet.
assertNull(record.lastSuccessInstant()); assertNull(createdRecord.lastSuccessInstant());
assertNull(record.lastFailureInstant()); 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 @Test
@@ -273,7 +289,7 @@ class DocumentProcessingCoordinatorTest {
} }
@Test @Test
void process_knownDocument_namingProposalReady_persistsProposalReadyStatus() { void process_knownDocument_namingProposalReady_persistsProposalReadyThenContinuesToSuccess() {
DocumentRecord existingRecord = buildRecord( DocumentRecord existingRecord = buildRecord(
ProcessingStatus.FAILED_RETRYABLE, ProcessingStatus.FAILED_RETRYABLE,
new FailureCounters(0, 1)); new FailureCounters(0, 1));
@@ -283,14 +299,22 @@ class DocumentProcessingCoordinatorTest {
processor.process(candidate, fingerprint, outcome, context, attemptStart); processor.process(candidate, fingerprint, outcome, context, attemptStart);
assertEquals(1, recordRepo.updatedRecords.size()); // Two record updates: first to PROPOSAL_READY (pipeline stage), then to SUCCESS (target-copy stage).
DocumentRecord record = recordRepo.updatedRecords.get(0); assertEquals(2, recordRepo.updatedRecords.size(),
assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus()); "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 // Counters unchanged on naming proposal success
assertEquals(0, record.failureCounters().contentErrorCount()); assertEquals(0, intermediate.failureCounters().contentErrorCount());
assertEquals(1, record.failureCounters().transientErrorCount()); assertEquals(1, intermediate.failureCounters().transientErrorCount());
// lastSuccessInstant is null after AI naming proposal; it is set only after the target-copy stage // lastSuccessInstant is still null after the intermediate PROPOSAL_READY write.
assertNull(record.lastSuccessInstant()); 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 @Test
void process_newDocument_namingProposalReady_failureClassAndMessageAreNull() { 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()); recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = buildNamingProposalOutcome(); DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
processor.process(candidate, fingerprint, outcome, context, attemptStart); processor.process(candidate, fingerprint, outcome, context, attemptStart);
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0); ProcessingAttempt proposalAttempt = attemptRepo.savedAttempts.stream()
assertNull(attempt.failureClass(), "Bei PROPOSAL_READY muss failureClass null sein"); .filter(a -> a.status() == ProcessingStatus.PROPOSAL_READY)
assertNull(attempt.failureMessage(), "Bei PROPOSAL_READY muss failureMessage null sein"); .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 @Test
void process_knownDocument_namingProposalReady_lastSuccessInstantNullAndLastFailureInstantFromPreviousRecord() { void process_knownDocument_namingProposalReady_intermediateRecordKeepsPreviousFailureAndHasNoSuccess() {
// Prüft, dass bei PROPOSAL_READY am known-Dokument lastSuccessInstant null bleibt // Prüft den persistierten Zwischenzustand (PROPOSAL_READY-Update) im selben Lauf:
// (wird erst nach der Zielkopie gesetzt) und lastFailureInstant aus dem Vorgänger übernommen wird // lastSuccessInstant bleibt null bis zur Zielkopie; lastFailureInstant aus dem Vorgänger bleibt erhalten.
Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z"); Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z");
DocumentRecord existingRecord = new DocumentRecord( DocumentRecord existingRecord = new DocumentRecord(
fingerprint, fingerprint,
@@ -625,11 +653,13 @@ class DocumentProcessingCoordinatorTest {
processor.process(candidate, fingerprint, outcome, context, attemptStart); processor.process(candidate, fingerprint, outcome, context, attemptStart);
DocumentRecord updated = recordRepo.updatedRecords.get(0); // Der erste Update-Record ist der Zwischenzustand (PROPOSAL_READY) vor der Finalisierung.
assertNull(updated.lastSuccessInstant(), DocumentRecord intermediate = recordRepo.updatedRecords.get(0);
"lastSuccessInstant muss nach PROPOSAL_READY null bleiben (wird erst nach der Zielkopie gesetzt)"); assertEquals(ProcessingStatus.PROPOSAL_READY, intermediate.overallStatus());
assertEquals(previousFailureInstant, updated.lastFailureInstant(), assertNull(intermediate.lastSuccessInstant(),
"lastFailureInstant muss bei PROPOSAL_READY den Vorgänger-Wert beibehalten"); "lastSuccessInstant muss im PROPOSAL_READY-Zwischenstand null bleiben");
assertEquals(previousFailureInstant, intermediate.lastFailureInstant(),
"lastFailureInstant muss im PROPOSAL_READY-Zwischenstand den Vorgänger-Wert beibehalten");
} }
@Test @Test
@@ -914,18 +944,18 @@ class DocumentProcessingCoordinatorTest {
} }
@Test @Test
void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds20Chars_persistsTransientError() { void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds60Chars_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero()); DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord)); 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( ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(), fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false, ProcessingStatus.PROPOSAL_READY, null, null, false,
null, null,
"model", "prompt", 1, 100, "{}", "reason", "model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"A".repeat(21), null); "A".repeat(61), null);
attemptRepo.savedAttempts.add(badProposal); attemptRepo.savedAttempts.add(badProposal);
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null); boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
@@ -20,7 +20,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/** /**
* Unit tests for {@link TargetFilenameBuildingService}. * Unit tests for {@link TargetFilenameBuildingService}.
* <p> * <p>
* 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), * base-title rule, the fachliche Titelregel (only letters, digits, and spaces),
* Windows-compatibility character removal, and the detection of inconsistent persistence * Windows-compatibility character removal, and the detection of inconsistent persistence
* states. * states.
@@ -100,8 +100,8 @@ class TargetFilenameBuildingServiceTest {
} }
@Test @Test
void buildBaseFilename_titleExactly20Chars_isAccepted() { void buildBaseFilename_titleExactly60Chars_isAccepted() {
String title = "A".repeat(20); // exactly 20 characters String title = "A".repeat(60); // exactly 60 characters
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title); ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); 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 @Test
void buildBaseFilename_format_separatorAndExtensionAreNotCountedAgainstTitle() { void buildBaseFilename_format_separatorAndExtensionAreNotCountedAgainstTitle() {
// A 20-char title produces "YYYY-MM-DD - <20chars>.pdf" — total > 20 chars, which is fine // 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); ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
@@ -170,19 +170,19 @@ class TargetFilenameBuildingServiceTest {
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// InconsistentProposalState title exceeds 20 characters // InconsistentProposalState title exceeds 60 characters
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@Test @Test
void buildBaseFilename_titleExceeds20Chars_returnsInconsistentProposalState() { void buildBaseFilename_titleExceeds60Chars_returnsInconsistentProposalState() {
String title = "A".repeat(21); // 21 characters String title = "A".repeat(61); // 61 characters
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title); ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class); assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason()) assertThat(((InconsistentProposalState) result).reason())
.contains("exceeding 20 characters"); .contains("exceeding 60 characters");
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -305,10 +305,10 @@ class TargetFilenameBuildingServiceTest {
} }
@Test @Test
void buildBaseFilename_twentyCharacterRuleUnaffectedByWindowsCompatibility() { void buildBaseFilename_sixtyCharacterRuleUnaffectedByWindowsCompatibility() {
// The 20-character rule applies to the base title only. // The 60-character rule applies to the base title only.
// Windows-compatibility cleaning does not change the length counting mechanism. // 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); ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt); BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
@@ -28,8 +28,10 @@ import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
* *
* <h2>End-to-end invariants verified</h2> * <h2>End-to-end invariants verified</h2>
* <ul> * <ul>
* <li><strong>Happy-path to {@code SUCCESS}</strong>: two-run flow via {@code PROPOSAL_READY} * <li><strong>Happy-path to {@code SUCCESS}</strong>: a single run produces a historised
* intermediate state to a final {@code SUCCESS} with a target file on disk.</li> * {@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 * <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 * {@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> * 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 * <li><strong>Skip after {@code FAILED_FINAL}</strong>: a document whose status is
* {@code FAILED_FINAL} generates exactly one {@code SKIPPED_FINAL_FAILURE} attempt * {@code FAILED_FINAL} generates exactly one {@code SKIPPED_FINAL_FAILURE} attempt
* in the next run; the overall status and failure counters remain unchanged.</li> * 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 * <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 * 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> * 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> * <ol>
* <li>Run 1: AI stub returns valid proposal → document status becomes * <li>AI stub returns a valid proposal; the coordinator first persists the
* {@code PROPOSAL_READY}; no target file yet.</li> * {@code PROPOSAL_READY} attempt (authoritative source for the naming proposal)
* <li>Run 2: AI is NOT called again; target file is copied; document status * and the master record transitions through {@code PROPOSAL_READY}.</li>
* becomes {@code SUCCESS}.</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> * </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 @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)) { try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
ctx.createSearchablePdf("rechnung.pdf", SAMPLE_PDF_TEXT); ctx.createSearchablePdf("rechnung.pdf", SAMPLE_PDF_TEXT);
Path pdfPath = ctx.sourceFolder().resolve("rechnung.pdf"); Path pdfPath = ctx.sourceFolder().resolve("rechnung.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath); DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// --- Run 1: AI produces a naming proposal --- // --- Single run: AI produces proposal → PROPOSAL_READY historised → SUCCESS ---
BatchRunOutcome run1 = ctx.runBatch(); BatchRunOutcome run = ctx.runBatch();
assertThat(run1).isEqualTo(BatchRunOutcome.SUCCESS); assertThat(run).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(ctx.aiStub.invocationCount()) assertThat(ctx.aiStub.invocationCount())
.as("AI must not be called again when PROPOSAL_READY exists") .as("AI must be invoked exactly once within the single run")
.isEqualTo(0); .isEqualTo(1);
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow(); DocumentRecord record = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS); assertThat(record.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(record2.lastSuccessInstant()).isNotNull(); assertThat(record.lastSuccessInstant()).isNotNull();
assertThat(record2.lastTargetFileName()).isNotNull(); assertThat(record.lastTargetFileName()).isNotNull();
List<String> targetFiles = ctx.listTargetFiles(); List<String> targetFiles = ctx.listTargetFiles();
assertThat(targetFiles).hasSize(1); assertThat(targetFiles).hasSize(1);
assertThat(targetFiles.get(0)).endsWith(".pdf"); assertThat(targetFiles.get(0)).endsWith(".pdf");
assertThat(Files.exists(ctx.targetFolder().resolve(targetFiles.get(0)))).isTrue(); assertThat(Files.exists(ctx.targetFolder().resolve(targetFiles.get(0)))).isTrue();
List<ProcessingAttempt> attempts2 = ctx.findAttempts(fp); // Two historised attempts: first PROPOSAL_READY, then SUCCESS with final target filename
assertThat(attempts2).hasSize(2); List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts2.get(1).status()).isEqualTo(ProcessingStatus.SUCCESS); 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: * 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, * {@code SKIPPED_ALREADY_PROCESSED} attempt without changing the overall status,
* failure counters, or the target file. * failure counters, or the target file.
*/ */
@Test @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)) { try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT); ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
Path pdfPath = ctx.sourceFolder().resolve("doc.pdf"); Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath); DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// Reach SUCCESS via two runs // Reach SUCCESS in a single run (PROPOSAL_READY → SUCCESS within the same run)
ctx.runBatch(); // → PROPOSAL_READY ctx.runBatch();
ctx.runBatch(); // → SUCCESS
DocumentRecord successRecord = ctx.findDocumentRecord(fp).orElseThrow(); DocumentRecord successRecord = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(successRecord.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS); assertThat(successRecord.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
String targetFileBefore = successRecord.lastTargetFileName(); String targetFileBefore = successRecord.lastTargetFileName();
// --- Run 3: should produce skip --- // --- Subsequent run: should produce skip ---
ctx.aiStub.resetInvocationCount(); 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()) assertThat(ctx.aiStub.invocationCount())
.as("AI must not be called for an already-successful document") .as("AI must not be called for an already-successful document")
.isEqualTo(0); .isEqualTo(0);
DocumentRecord record3 = ctx.findDocumentRecord(fp).orElseThrow(); DocumentRecord recordAfterSkip = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record3.overallStatus()) assertThat(recordAfterSkip.overallStatus())
.as("Overall status must remain SUCCESS after a skip") .as("Overall status must remain SUCCESS after a skip")
.isEqualTo(ProcessingStatus.SUCCESS); .isEqualTo(ProcessingStatus.SUCCESS);
assertThat(record3.lastTargetFileName()) assertThat(recordAfterSkip.lastTargetFileName())
.as("Target filename must not change after a skip") .as("Target filename must not change after a skip")
.isEqualTo(targetFileBefore); .isEqualTo(targetFileBefore);
// Three historised attempts total: PROPOSAL_READY, SUCCESS, SKIPPED_ALREADY_PROCESSED
List<ProcessingAttempt> attempts = ctx.findAttempts(fp); List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(3); 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).status()).isEqualTo(ProcessingStatus.SKIPPED_ALREADY_PROCESSED);
assertThat(attempts.get(2).retryable()).isFalse(); 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: * Verifies the immediate within-run retry for target copy failures within the
* <ol> * single-run pipeline:
* <li>Run 1: AI produces a naming proposal → document status is {@code PROPOSAL_READY}.</li> * <ul>
* <li>Run 2: AI stub is reset to technical failure; the coordinator must still finalize * <li>The {@link TargetFileCopyPort} is overridden with a stub that fails on the
* the document to {@code SUCCESS} using the persisted proposal — without calling the AI.</li> * first invocation but delegates to the real adapter on the second.</li>
* </ol> * <li>Within the same run the AI stub produces a naming proposal, the
* This confirms that the second run never re-invokes the AI when a valid * {@code PROPOSAL_READY} attempt is historised, the first copy attempt fails,
* {@code PROPOSAL_READY} attempt already exists. * and the immediate within-run retry succeeds.</li>
*/ * <li>The document is recorded as {@code SUCCESS} — without incrementing the
@Test * cross-run transient error counter.</li>
void proposalReadyFinalization_noAiCallInSecondRun(@TempDir Path tempDir) throws Exception { * </ul>
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>
* The immediate retry does not count as a cross-run transient error. * The immediate retry does not count as a cross-run transient error.
*/ */
@Test @Test
@@ -392,13 +350,7 @@ class BatchRunEndToEndTest {
Path pdfPath = ctx.sourceFolder().resolve("doc.pdf"); Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath); DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// --- Run 1: produce PROPOSAL_READY --- // The copy port fails once and succeeds on the immediate within-run retry.
ctx.runBatch();
DocumentRecord record1 = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record1.overallStatus()).isEqualTo(ProcessingStatus.PROPOSAL_READY);
// --- Run 2: first copy attempt fails, retry succeeds ---
TargetFileCopyPort realAdapter = TargetFileCopyPort realAdapter =
new de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter( new de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter(
ctx.targetFolder()); ctx.targetFolder());
@@ -419,17 +371,20 @@ class BatchRunEndToEndTest {
ctx.runBatch(); ctx.runBatch();
assertThat(copyCallCount.get()) 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); .isEqualTo(2);
DocumentRecord record2 = ctx.findDocumentRecord(fp).orElseThrow(); DocumentRecord record = ctx.findDocumentRecord(fp).orElseThrow();
assertThat(record2.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS); assertThat(record.overallStatus()).isEqualTo(ProcessingStatus.SUCCESS);
assertThat(record2.failureCounters().transientErrorCount()) assertThat(record.failureCounters().transientErrorCount())
.as("Immediate within-run retry must not increment the transient error counter") .as("Immediate within-run retry must not increment the transient error counter")
.isEqualTo(0); .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); List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(2); 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).status()).isEqualTo(ProcessingStatus.SUCCESS);
List<String> targetFiles = ctx.listTargetFiles(); List<String> targetFiles = ctx.listTargetFiles();
@@ -515,15 +470,16 @@ class BatchRunEndToEndTest {
// ========================================================================= // =========================================================================
/** /**
* Verifies the failure path of the immediate within-run retry mechanism: * Verifies the failure path of the immediate within-run retry mechanism, within the
* <ol> * single-run pipeline:
* <li>Run 1: AI stub returns a valid proposal → {@code PROPOSAL_READY}.</li> * <ul>
* <li>Run 2: The {@link TargetFileCopyPort} is overridden with a stub that fails * <li>The {@link TargetFileCopyPort} is overridden with a stub that fails on every call.</li>
* on every call. The coordinator issues the initial copy attempt (failure), * <li>Within a single run the AI stub produces a valid proposal, the
* grants exactly one immediate retry (also failure), then classifies the * {@code PROPOSAL_READY} attempt is historised, the initial copy attempt fails,
* result as a transient technical error and records {@code FAILED_RETRYABLE} * the immediate within-run retry also fails, and the combined result is
* with an incremented transient counter.</li> * classified as a transient technical error and persisted as
* </ol> * {@code FAILED_RETRYABLE} with an incremented transient counter.</li>
* </ul>
* This confirms that the within-run retry does not suppress the error when both * 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. * attempts fail, and that the transient counter is incremented exactly once.
*/ */
@@ -535,13 +491,7 @@ class BatchRunEndToEndTest {
Path pdfPath = ctx.sourceFolder().resolve("doc.pdf"); Path pdfPath = ctx.sourceFolder().resolve("doc.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath); DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// --- Run 1: establish PROPOSAL_READY --- // Both copy attempts (initial + immediate within-run retry) fail deterministically
ctx.runBatch();
assertThat(ctx.findDocumentRecord(fp).orElseThrow().overallStatus())
.isEqualTo(ProcessingStatus.PROPOSAL_READY);
// --- Run 2: both copy attempts fail ---
ctx.setTargetFileCopyPortOverride( ctx.setTargetFileCopyPortOverride(
(locator, resolvedFilename) -> (locator, resolvedFilename) ->
new TargetFileCopyTechnicalFailure( new TargetFileCopyTechnicalFailure(
@@ -558,8 +508,11 @@ class BatchRunEndToEndTest {
.as("The double copy failure must increment the transient counter exactly once") .as("The double copy failure must increment the transient counter exactly once")
.isEqualTo(1); .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); List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
assertThat(attempts).hasSize(2); 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).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
assertThat(attempts.get(1).retryable()).isTrue(); assertThat(attempts.get(1).retryable()).isTrue();
@@ -574,15 +527,13 @@ class BatchRunEndToEndTest {
/** /**
* Verifies the duplicate target filename suffix rule at end-to-end level: * 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 * 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 * ({@code "2024-01-15 - Stromabrechnung.pdf"}) within the same single-run pipeline,
* second document written to the target folder must receive a {@code (1)} suffix. * the second document written to the target folder must receive a {@code (1)} suffix.
* <ol> * <p>
* <li>Run 1: both PDFs are processed by the AI stub (same configured response) → * Both PDFs are processed sequentially in the same run: each one goes through
* both reach {@code PROPOSAL_READY}.</li> * AI-proposal → {@code PROPOSAL_READY} → target copy → {@code SUCCESS}. The first
* <li>Run 2: both are finalized in sequence; the first written claims the base name, * written claims the base name, the second receives
* the second receives {@code "2024-01-15 - Stromabrechnung(1).pdf"}.</li> * {@code "2024-01-15 - Stromabrechnung(1).pdf"}.
* </ol>
* Both documents reach {@code SUCCESS} and the target folder contains exactly two files.
*/ */
@Test @Test
void twoDifferentDocuments_sameProposedName_secondGetsDuplicateSuffix(@TempDir Path tempDir) void twoDifferentDocuments_sameProposedName_secondGetsDuplicateSuffix(@TempDir Path tempDir)
@@ -598,16 +549,7 @@ class BatchRunEndToEndTest {
DocumentFingerprint fp1 = ctx.computeFingerprint(pdf1); DocumentFingerprint fp1 = ctx.computeFingerprint(pdf1);
DocumentFingerprint fp2 = ctx.computeFingerprint(pdf2); DocumentFingerprint fp2 = ctx.computeFingerprint(pdf2);
// --- Run 1: AI stub processes both PDFs → PROPOSAL_READY --- // --- Single run: both PDFs are processed and copied within the same run ---
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 ---
ctx.runBatch(); ctx.runBatch();
assertThat(ctx.findDocumentRecord(fp1).orElseThrow().overallStatus()) assertThat(ctx.findDocumentRecord(fp1).orElseThrow().overallStatus())
@@ -635,10 +577,10 @@ class BatchRunEndToEndTest {
/** /**
* Verifies that document-level failures do not cause a batch-level failure: * Verifies that document-level failures do not cause a batch-level failure:
* <ol> * <ol>
* <li>Run 1: a searchable PDF reaches {@code PROPOSAL_READY}; a blank PDF * <li>Run 1: a searchable PDF reaches {@code SUCCESS} within the same run
* (no extractable text) reaches {@code FAILED_RETRYABLE}. * (PROPOSAL_READY → SUCCESS); a blank PDF (no extractable text) reaches
* {@link BatchRunOutcome#SUCCESS} is returned.</li> * {@code FAILED_RETRYABLE}. {@link BatchRunOutcome#SUCCESS} is returned.</li>
* <li>Run 2: the searchable PDF is finalized to {@code SUCCESS}; * <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 * the blank PDF reaches its second content error and is finalized to
* {@code FAILED_FINAL}. {@link BatchRunOutcome#SUCCESS} is returned.</li> * {@code FAILED_FINAL}. {@link BatchRunOutcome#SUCCESS} is returned.</li>
* </ol> * </ol>
@@ -664,12 +606,18 @@ class BatchRunEndToEndTest {
.as("Batch must complete with SUCCESS even when individual documents fail") .as("Batch must complete with SUCCESS even when individual documents fail")
.isEqualTo(BatchRunOutcome.SUCCESS); .isEqualTo(BatchRunOutcome.SUCCESS);
assertThat(ctx.findDocumentRecord(fpGood).orElseThrow().overallStatus()) 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()) assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow().overallStatus())
.isEqualTo(ProcessingStatus.FAILED_RETRYABLE); .isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow() assertThat(ctx.findDocumentRecord(fpBlank).orElseThrow()
.failureCounters().contentErrorCount()).isEqualTo(1); .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 --- // --- Run 2 ---
BatchRunOutcome run2 = ctx.runBatch(); BatchRunOutcome run2 = ctx.runBatch();
@@ -679,7 +627,9 @@ class BatchRunEndToEndTest {
.isEqualTo(BatchRunOutcome.SUCCESS); .isEqualTo(BatchRunOutcome.SUCCESS);
DocumentRecord goodRecord = ctx.findDocumentRecord(fpGood).orElseThrow(); 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(); DocumentRecord blankRecord = ctx.findDocumentRecord(fpBlank).orElseThrow();
assertThat(blankRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL); assertThat(blankRecord.overallStatus()).isEqualTo(ProcessingStatus.FAILED_FINAL);
@@ -60,10 +60,12 @@ class ProviderIdentifierE2ETest {
* Regression proof: the OpenAI-compatible provider path still produces the correct * Regression proof: the OpenAI-compatible provider path still produces the correct
* end-to-end outcome after the multi-provider extension. * end-to-end outcome after the multi-provider extension.
* <p> * <p>
* Runs the two-phase happy path (AI call → {@code PROPOSAL_READY} in run 1, * Runs the single-run happy path (AI call produces the proposal, the
* file copy → {@code SUCCESS} in run 2) with the {@code openai-compatible} provider * {@code PROPOSAL_READY} attempt is historised, and the target-copy finalization
* identifier and verifies the final state matches the expected success outcome. * transitions the document to {@code SUCCESS} within the same run) with the
* This is the canonical regression check for the existing OpenAI flow. * {@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 @Test
void regressionExistingOpenAiSuiteGreen(@TempDir Path tempDir) throws Exception { void regressionExistingOpenAiSuiteGreen(@TempDir Path tempDir) throws Exception {
@@ -72,20 +74,12 @@ class ProviderIdentifierE2ETest {
Path pdfPath = ctx.sourceFolder().resolve("regression.pdf"); Path pdfPath = ctx.sourceFolder().resolve("regression.pdf");
DocumentFingerprint fp = ctx.computeFingerprint(pdfPath); DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);
// Run 1: AI produces naming proposal // Single run: AI proposal + target copy within the same run → SUCCESS
BatchRunOutcome run1 = ctx.runBatch(); BatchRunOutcome run = ctx.runBatch();
assertThat(run1).isEqualTo(BatchRunOutcome.SUCCESS); assertThat(run).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);
assertThat(ctx.aiStub.invocationCount()) assertThat(ctx.aiStub.invocationCount())
.as("Existing OpenAI path must not re-invoke AI when PROPOSAL_READY exists") .as("OpenAI-compatible path must invoke the AI exactly once per document")
.isEqualTo(0); .isEqualTo(1);
assertThat(resolveRecord(ctx, fp).overallStatus()) assertThat(resolveRecord(ctx, fp).overallStatus())
.isEqualTo(ProcessingStatus.SUCCESS); .isEqualTo(ProcessingStatus.SUCCESS);
assertThat(ctx.listTargetFiles()).hasSize(1); assertThat(ctx.listTargetFiles()).hasSize(1);
@@ -99,7 +93,12 @@ class ProviderIdentifierE2ETest {
/** /**
* Verifies that a batch run using the {@code openai-compatible} provider identifier * Verifies that a batch run using the {@code openai-compatible} provider identifier
* persists {@code "openai-compatible"} in the {@code ai_provider} field of the * 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 @Test
void e2eOpenAiRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception { void e2eOpenAiRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception {
@@ -110,10 +109,16 @@ class ProviderIdentifierE2ETest {
ctx.runBatch(); ctx.runBatch();
List<ProcessingAttempt> attempts = ctx.findAttempts(fp); 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()) 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"); .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 * 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> * <p>
* The AI invocation itself is still handled by the configurable {@link StubAiInvocationPort}; * 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. * 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 @Test
void e2eClaudeRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception { void e2eClaudeRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception {
@@ -137,10 +145,16 @@ class ProviderIdentifierE2ETest {
ctx.runBatch(); ctx.runBatch();
List<ProcessingAttempt> attempts = ctx.findAttempts(fp); 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()) 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"); .isEqualTo("claude");
assertThat(attempts.get(1).status())
.as("Second historised attempt must finalise the document as SUCCESS")
.isEqualTo(ProcessingStatus.SUCCESS);
} }
} }