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:
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+3
-3
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51
-4
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+6
-6
@@ -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 + "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -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);
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
|
|||||||
+69
-39
@@ -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);
|
||||||
|
|||||||
+12
-12
@@ -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);
|
||||||
|
|||||||
+109
-159
@@ -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);
|
||||||
|
|||||||
+37
-23
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user