|
|
|
|
@@ -829,8 +829,9 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
// No PROPOSAL_READY attempt pre-populated
|
|
|
|
|
|
|
|
|
|
// persistTransientError returns true when the error record was persisted successfully
|
|
|
|
|
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
|
|
|
|
|
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
|
|
|
|
|
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
|
|
|
|
|
.findFirst()
|
|
|
|
|
@@ -851,8 +852,9 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
null, DateSource.AI_PROVIDED, "Rechnung", null);
|
|
|
|
|
attemptRepo.savedAttempts.add(badProposal);
|
|
|
|
|
|
|
|
|
|
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
|
|
|
|
|
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
|
|
|
|
|
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
|
|
|
|
|
.findFirst()
|
|
|
|
|
@@ -871,8 +873,10 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithFailingFolder.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
boolean result = coordinatorWithFailingFolder.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
|
|
|
|
|
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
|
|
|
|
|
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
|
|
|
|
|
.findFirst()
|
|
|
|
|
@@ -891,8 +895,10 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger(),
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithFailingCopy.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
boolean result = coordinatorWithFailingCopy.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
|
|
|
|
|
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
|
|
|
|
|
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
|
|
|
|
|
.findFirst()
|
|
|
|
|
@@ -914,8 +920,9 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
"A".repeat(21), null);
|
|
|
|
|
attemptRepo.savedAttempts.add(badProposal);
|
|
|
|
|
|
|
|
|
|
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
|
|
|
|
|
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
|
|
|
|
|
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
|
|
|
|
|
.findFirst()
|
|
|
|
|
@@ -939,8 +946,9 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
"Rechnung-2026", null);
|
|
|
|
|
attemptRepo.savedAttempts.add(badProposal);
|
|
|
|
|
|
|
|
|
|
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
|
|
|
|
|
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
|
|
|
|
|
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
|
|
|
|
|
.findFirst()
|
|
|
|
|
@@ -1008,9 +1016,10 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
new NoOpTargetFolderPort(), countingCopyPort, new NoOpProcessingLogger(),
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCountingCopy.processDeferredOutcome(
|
|
|
|
|
boolean result = coordinatorWithCountingCopy.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
|
|
|
|
|
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
|
|
|
|
|
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
|
|
|
|
|
.findFirst()
|
|
|
|
|
@@ -1037,9 +1046,10 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), failingCopy, new NoOpProcessingLogger(), 1);
|
|
|
|
|
|
|
|
|
|
coordinatorWith1Retry.processDeferredOutcome(
|
|
|
|
|
boolean result = coordinatorWith1Retry.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(result, "processDeferredOutcome must return true when the transient error is persisted successfully");
|
|
|
|
|
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
|
|
|
|
|
.filter(a -> a.status() == ProcessingStatus.FAILED_FINAL)
|
|
|
|
|
.findFirst()
|
|
|
|
|
@@ -1055,6 +1065,58 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
"Transient error counter must be 1 after the first cross-run transient error");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_copyFailure_retryDecisionLog_containsFailedRetryable() {
|
|
|
|
|
// Verifies that when a copy failure leads to FAILED_RETRYABLE in persistTransientError,
|
|
|
|
|
// the retry-decision log message specifically contains "FAILED_RETRYABLE" and
|
|
|
|
|
// "will retry in later run" — the branch-specific text that distinguishes it from the
|
|
|
|
|
// FAILED_FINAL branch. This kills the negated-conditional mutation on the retryable flag check.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
|
|
|
|
|
MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.anyInfoContains("FAILED_RETRYABLE"),
|
|
|
|
|
"Retry decision log for a retryable transient copy error must contain FAILED_RETRYABLE. "
|
|
|
|
|
+ "Captured info messages: " + capturingLogger.infoMessages);
|
|
|
|
|
assertTrue(capturingLogger.anyInfoContains("will retry in later run"),
|
|
|
|
|
"Retry decision log for a retryable transient error must contain 'will retry in later run'. "
|
|
|
|
|
+ "Captured info messages: " + capturingLogger.infoMessages);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_copyFailure_maxRetriesTransient1_retryDecisionLog_containsFailedFinal() {
|
|
|
|
|
// Verifies that when a copy failure with maxRetriesTransient=1 leads to FAILED_FINAL in
|
|
|
|
|
// persistTransientError, the retry-decision log message contains "FAILED_FINAL" and
|
|
|
|
|
// "transient error limit reached" — the branch-specific text that distinguishes it
|
|
|
|
|
// from the FAILED_RETRYABLE branch.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
|
|
|
|
|
MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), capturingLogger,
|
|
|
|
|
1 /* maxRetriesTransient=1 → immediately final */);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.anyInfoContains("FAILED_FINAL"),
|
|
|
|
|
"Retry decision log for a finalising transient copy error must contain FAILED_FINAL. "
|
|
|
|
|
+ "Captured info messages: " + capturingLogger.infoMessages);
|
|
|
|
|
assertTrue(capturingLogger.anyInfoContains("transient error limit reached"),
|
|
|
|
|
"Retry decision log for a finalising transient error must contain 'transient error limit reached'. "
|
|
|
|
|
+ "Captured info messages: " + capturingLogger.infoMessages);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_immediateRetryDoesNotTriggerAiOrNewProposal() {
|
|
|
|
|
// Ensures that during the immediate retry path no pipeline (AI) execution happens
|
|
|
|
|
@@ -1375,6 +1437,26 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Counts calls to {@link #tryDeleteTargetFile(String)} for mutation detection. */
|
|
|
|
|
private static class CapturingTargetFolderPort implements TargetFolderPort {
|
|
|
|
|
int tryDeleteCallCount = 0;
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public String getTargetFolderLocator() {
|
|
|
|
|
return "/tmp/target";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
|
|
|
|
|
return new ResolvedTargetFilename(baseName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public void tryDeleteTargetFile(String resolvedFilename) {
|
|
|
|
|
tryDeleteCallCount++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static class NoOpTargetFolderPort implements TargetFolderPort {
|
|
|
|
|
@Override
|
|
|
|
|
public String getTargetFolderLocator() {
|
|
|
|
|
@@ -1493,6 +1575,162 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
assertTrue(capturingLogger.anyInfoContains("FAILED_FINAL"),
|
|
|
|
|
"Finalising retry decision log must contain the FAILED_FINAL classification. "
|
|
|
|
|
+ "Captured info messages: " + capturingLogger.infoMessages);
|
|
|
|
|
assertTrue(capturingLogger.anyInfoContains("permanently failed"),
|
|
|
|
|
"Finalising retry decision log must contain 'permanently failed' to distinguish "
|
|
|
|
|
+ "the FAILED_FINAL branch from the generic status log. "
|
|
|
|
|
+ "Captured info messages: " + capturingLogger.infoMessages);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Finalization path logging: error, warn, and info calls in key paths
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_missingProposalAttempt_logsError() {
|
|
|
|
|
// Missing PROPOSAL_READY attempt in history — finalizeProposalReady must log an error.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
// No attempt pre-loaded — proposalAttempt == null branch
|
|
|
|
|
|
|
|
|
|
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.errorCallCount > 0,
|
|
|
|
|
"An error must be logged when the PROPOSAL_READY attempt is missing from history");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_inconsistentProposalState_logsError() {
|
|
|
|
|
// Inconsistent proposal state (null date) — finalizeProposalReady must log an error.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
ProcessingAttempt badProposal = new ProcessingAttempt(
|
|
|
|
|
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
|
|
|
|
|
ProcessingStatus.PROPOSAL_READY, null, null, false,
|
|
|
|
|
"model", "prompt", 1, 100, "{}", "reason",
|
|
|
|
|
null, DateSource.AI_PROVIDED, "Rechnung", null);
|
|
|
|
|
attemptRepo.savedAttempts.add(badProposal);
|
|
|
|
|
|
|
|
|
|
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.errorCallCount > 0,
|
|
|
|
|
"An error must be logged when the proposal state is inconsistent");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_duplicateResolutionFailure_logsError() {
|
|
|
|
|
// Duplicate resolution failure — finalizeProposalReady must log an error.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
|
|
|
|
|
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.errorCallCount > 0,
|
|
|
|
|
"An error must be logged when duplicate resolution fails");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_resolvedFilename_logsInfo() {
|
|
|
|
|
// Successful duplicate resolution — resolved filename must be logged at INFO.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
|
|
|
|
|
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart,
|
|
|
|
|
c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.infoCallCount > 0,
|
|
|
|
|
"Resolved target filename must be logged at INFO level");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_firstCopyFails_logsWarn() {
|
|
|
|
|
// First copy attempt fails → immediate retry: a WARN must be logged for the first failure.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
|
|
|
|
|
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
|
|
|
CountingTargetFileCopyPort onlyFirstFails = new CountingTargetFileCopyPort(1);
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart,
|
|
|
|
|
c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.warnCallCount > 0,
|
|
|
|
|
"A WARN must be logged when the first copy attempt fails and an immediate retry is triggered");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_bothCopyAttemptsFail_logsError() {
|
|
|
|
|
// Both copy attempts fail — finalizeProposalReady must log an error.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
|
|
|
|
|
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
|
|
|
CountingTargetFileCopyPort bothFail = new CountingTargetFileCopyPort(2);
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), bothFail, capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.errorCallCount > 0,
|
|
|
|
|
"An error must be logged when both copy attempts fail");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_immediateRetrySucceeds_logsInfo() {
|
|
|
|
|
// First copy fails, immediate retry succeeds — a success INFO must be logged.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
|
|
|
|
|
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
|
|
|
CountingTargetFileCopyPort onlyFirstFails = new CountingTargetFileCopyPort(1);
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), onlyFirstFails, capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart,
|
|
|
|
|
c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.infoCallCount > 0,
|
|
|
|
|
"An INFO must be logged when the immediate within-run retry succeeds");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Zählt Logger-Aufrufe je Level, um VoidMethodCallMutator-Mutationen zu erkennen. */
|
|
|
|
|
@@ -1581,5 +1819,91 @@ class DocumentProcessingCoordinatorTest {
|
|
|
|
|
boolean anyInfoContains(String text) {
|
|
|
|
|
return infoMessages.stream().anyMatch(m -> m.contains(text));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
boolean anyErrorContains(String text) {
|
|
|
|
|
return errorMessages.stream().anyMatch(m -> m.contains(text));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// AI sensitive content logging in finalization path
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_aiContentNotNull_callsDebugSensitiveAiContent() {
|
|
|
|
|
// buildValidProposalAttempt() has non-null aiRawResponse and aiReasoning.
|
|
|
|
|
// The conditional guards at lines 398 and 402 of finalizeProposalReady must
|
|
|
|
|
// trigger the debugSensitiveAiContent call when the values are present.
|
|
|
|
|
// If negated, the calls would be suppressed for non-null values — detectable here.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt()); // aiRawResponse="{}", aiReasoning="reason"
|
|
|
|
|
|
|
|
|
|
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(
|
|
|
|
|
candidate, fingerprint, context, attemptStart,
|
|
|
|
|
c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.debugSensitiveAiContentCallCount >= 2,
|
|
|
|
|
"debugSensitiveAiContent must be called for aiRawResponse and aiReasoning "
|
|
|
|
|
+ "when both are non-null. Actual call count: "
|
|
|
|
|
+ capturingLogger.debugSensitiveAiContentCallCount);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// Best-effort rollback path: tryDeleteTargetFile and secondary persistence
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_persistenceFailureAfterCopy_callsTryDeleteTargetFile() {
|
|
|
|
|
// When persistence fails after a successful copy, the best-effort rollback
|
|
|
|
|
// must call tryDeleteTargetFile to clean up the orphaned target file.
|
|
|
|
|
// This test kills the 'removed call to tryDeleteTargetFile' mutation.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
unitOfWorkPort.failOnExecute = true;
|
|
|
|
|
|
|
|
|
|
CapturingTargetFolderPort capturingFolderPort = new CapturingTargetFolderPort();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
capturingFolderPort, new NoOpTargetFileCopyPort(), new NoOpProcessingLogger(),
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingFolderPort.tryDeleteCallCount > 0,
|
|
|
|
|
"tryDeleteTargetFile must be called at least once for best-effort rollback "
|
|
|
|
|
+ "when persistence fails after a successful copy");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
void processDeferredOutcome_proposalReady_persistenceFailureAfterCopy_logsSecondaryFailure() {
|
|
|
|
|
// When persistence fails after a successful copy and the secondary persistence
|
|
|
|
|
// attempt in persistTransientErrorAfterPersistenceFailure also fails,
|
|
|
|
|
// an error must be logged for the secondary failure.
|
|
|
|
|
// This kills the 'removed call to persistTransientErrorAfterPersistenceFailure' mutation.
|
|
|
|
|
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
|
|
|
|
|
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
|
|
|
|
|
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
|
|
|
|
|
unitOfWorkPort.failOnExecute = true; // both primary and secondary persistence fail
|
|
|
|
|
|
|
|
|
|
MessageCapturingProcessingLogger capturingLogger = new MessageCapturingProcessingLogger();
|
|
|
|
|
DocumentProcessingCoordinator coordinatorWithCapturing = new DocumentProcessingCoordinator(
|
|
|
|
|
recordRepo, attemptRepo, unitOfWorkPort,
|
|
|
|
|
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger,
|
|
|
|
|
DEFAULT_MAX_RETRIES_TRANSIENT);
|
|
|
|
|
|
|
|
|
|
coordinatorWithCapturing.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
|
|
|
|
|
|
|
|
|
|
assertTrue(capturingLogger.anyErrorContains("Secondary persistence failure")
|
|
|
|
|
|| capturingLogger.anyErrorContains("secondary"),
|
|
|
|
|
"An error must be logged for the secondary persistence failure. "
|
|
|
|
|
+ "Captured error messages: " + capturingLogger.errorMessages);
|
|
|
|
|
}
|
|
|
|
|
}
|