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