diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java
index e88c24c..9959914 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultBatchRunProcessingUseCase.java
@@ -159,13 +159,19 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
/**
* Loads candidates and processes them one by one.
*
- * Tracks whether any document-level persistence failures occur during processing.
- * A persistence failure for a single document causes the overall batch outcome
- * to be FAILURE instead of SUCCESS.
+ * Document-level failures — including content errors, transient technical errors,
+ * and individual persistence failures — do not affect the batch outcome. The batch
+ * completes with {@link BatchRunOutcome#SUCCESS} as long as the source folder is accessible
+ * and the processing loop runs to completion without a hard infrastructure error.
+ * Document-level persistence failures are logged by the coordinator and retried in
+ * subsequent runs; they must not escalate to a hard batch failure.
+ *
+ * Only a hard source folder access failure ({@link SourceDocumentAccessException}) prevents
+ * the batch from running at all, in which case {@link BatchRunOutcome#FAILURE} is returned.
*
* @param context the current batch run context
- * @return SUCCESS if all candidates were processed without persistence failures,
- * FAILURE if source access fails or any document-level persistence failure occurred
+ * @return {@link BatchRunOutcome#SUCCESS} after all candidates have been processed,
+ * or {@link BatchRunOutcome#FAILURE} if the source folder is inaccessible
*/
private BatchRunOutcome processCandidates(BatchRunContext context) {
List candidates;
@@ -177,24 +183,13 @@ public class DefaultBatchRunProcessingUseCase implements BatchRunProcessingUseCa
}
logger.info("Found {} PDF candidate(s) in source folder.", candidates.size());
- // Track whether any document-level persistence failures occurred
- boolean anyPersistenceFailure = false;
-
- // Process each candidate
for (SourceDocumentCandidate candidate : candidates) {
- if (!processCandidate(candidate, context)) {
- anyPersistenceFailure = true;
- }
+ processCandidate(candidate, context);
}
logger.info("Batch run completed. Processed {} candidate(s). RunId: {}",
candidates.size(), context.runId());
- if (anyPersistenceFailure) {
- logger.warn("Batch run completed with document-level persistence failure(s).");
- return BatchRunOutcome.FAILURE;
- }
-
return BatchRunOutcome.SUCCESS;
}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
index b3051f7..dcf3509 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/BatchRunProcessingUseCaseTest.java
@@ -450,11 +450,13 @@ class BatchRunProcessingUseCaseTest {
// -------------------------------------------------------------------------
/**
- * Regression test: when a document-level persistence failure occurs,
- * the batch outcome must be FAILURE, not SUCCESS.
+ * Document-level persistence failures must not escalate to a hard batch failure.
+ * The batch outcome must be SUCCESS even when a document's persistence step fails,
+ * because the batch loop ran to completion and the failed document will be retried
+ * in a subsequent run.
*/
@Test
- void execute_documentPersistenceFailure_batchOutcomeIsFailure() throws Exception {
+ void execute_documentPersistenceFailure_batchOutcomeIsSuccess() throws Exception {
MockRunLockPort lockPort = new MockRunLockPort();
RuntimeConfiguration config = buildConfig(tempDir);
@@ -487,16 +489,20 @@ class BatchRunProcessingUseCaseTest {
BatchRunOutcome outcome = useCase.execute(context);
- assertTrue(outcome.isFailure(), "Document persistence failure should yield FAILURE outcome");
- assertFalse(outcome.isSuccess(), "Batch must not succeed when document persistence failed");
+ assertTrue(outcome.isSuccess(),
+ "Document-level persistence failure must not escalate to batch FAILURE — "
+ + "the batch ran to completion and the document will be retried");
+ assertFalse(outcome.isFailure(),
+ "Batch must not signal FAILURE when only a document-level persistence error occurred");
}
/**
- * Regression test: mixed batch where one document succeeds and one has persistence failure.
- * The batch outcome must be FAILURE due to the persistence failure.
+ * Mixed batch: one document completes normally, one has a persistence failure.
+ * The batch outcome must still be SUCCESS because both documents were processed
+ * by the loop. Document-level failures do not escalate to exit code 1.
*/
@Test
- void execute_mixedBatch_oneCandidateSuccess_oneDocumentPersistenceFails_batchIsFailure() throws Exception {
+ void execute_mixedBatch_oneCandidateSuccess_oneDocumentPersistenceFails_batchIsSuccess() throws Exception {
MockRunLockPort lockPort = new MockRunLockPort();
RuntimeConfiguration config = buildConfig(tempDir);
@@ -534,9 +540,11 @@ class BatchRunProcessingUseCaseTest {
BatchRunOutcome outcome = useCase.execute(context);
- assertTrue(outcome.isFailure(),
- "Batch must fail when any document has a persistence failure, even if others succeeded");
- assertFalse(outcome.isSuccess(), "Cannot be SUCCESS when persistence failed for any document");
+ assertTrue(outcome.isSuccess(),
+ "Batch must be SUCCESS even when one document had a persistence failure — "
+ + "the batch loop ran to completion");
+ assertFalse(outcome.isFailure(),
+ "Document-level persistence failure in one candidate must not make the batch FAILURE");
}
// -------------------------------------------------------------------------
@@ -587,43 +595,6 @@ class BatchRunProcessingUseCaseTest {
"Bei Quellordner-Zugriffsfehler muss ein Fehler geloggt werden");
}
- @Test
- void execute_withPersistenceFailure_logsWarning() throws Exception {
- // Prüft, dass nach Batch-Lauf mit Persistenzfehler eine Warnung geloggt wird
- CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
- RuntimeConfiguration config = buildConfig(tempDir);
-
- SourceDocumentCandidate candidate = makeCandidate("doc.pdf");
- FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
- FixedExtractionPort extractionPort = new FixedExtractionPort(
- new PdfExtractionSuccess("text", new PdfPageCount(1)));
-
- // Coordinator der immer Persistenzfehler zurückgibt
- DocumentProcessingCoordinator failingCoordinator = new DocumentProcessingCoordinator(
- new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
- new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
- new NoOpProcessingLogger(), 3) {
- @Override
- public boolean processDeferredOutcome(
- de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate c,
- de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint fp,
- de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext ctx,
- java.time.Instant start,
- java.util.function.Function exec) {
- return false;
- }
- };
-
- DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
- config, new MockRunLockPort(), candidatesPort, extractionPort,
- new AlwaysSuccessFingerprintPort(), failingCoordinator, buildStubAiNamingService(), capturingLogger);
-
- useCase.execute(new BatchRunContext(new RunId("persist-warn"), Instant.now()));
-
- assertTrue(capturingLogger.warnCallCount > 0,
- "Nach Batch-Lauf mit Persistenzfehler muss eine Warnung geloggt werden");
- }
-
@Test
void execute_batchStart_logsInfo() throws Exception {
// Prüft, dass beim Batch-Start mindestens die erwarteten Info-Einträge geloggt werden.
diff --git a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java
index 4c0d435..790f339 100644
--- a/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java
+++ b/pdf-umbenenner-bootstrap/src/test/java/de/gecheckt/pdf/umbenenner/bootstrap/BootstrapRunnerTest.java
@@ -245,6 +245,77 @@ class BootstrapRunnerTest {
assertNotNull(runner, "Default constructor should create a valid BootstrapRunner");
}
+ /**
+ * Verifies that max.retries.transient = 0 is rejected as invalid startup configuration
+ * through the full bootstrap path using the real validator. The value 0 is explicitly
+ * prohibited; only integers >= 1 are valid. Invalid startup configuration must prevent
+ * the batch from starting and must produce exit code 1.
+ */
+ @Test
+ void run_returnsOneWhenMaxRetriesTransientIsZeroViaRealValidator() throws Exception {
+ Path sourceDir = Files.createDirectories(tempDir.resolve("source-mrt"));
+ Path targetDir = Files.createDirectories(tempDir.resolve("target-mrt"));
+ Path dbFile = Files.createFile(tempDir.resolve("db-mrt.sqlite"));
+ Path promptFile = Files.createFile(tempDir.resolve("prompt-mrt.txt"));
+
+ StartConfiguration configWithZeroRetries = new StartConfiguration(
+ sourceDir,
+ targetDir,
+ dbFile,
+ java.net.URI.create("https://api.example.com"),
+ "gpt-4",
+ 30,
+ 0, // max.retries.transient = 0 is invalid (must be >= 1)
+ 100,
+ 50000,
+ promptFile,
+ tempDir.resolve("lock-mrt.lock"),
+ null,
+ "INFO",
+ "test-key",
+ false
+ );
+
+ BootstrapRunner runner = new BootstrapRunner(
+ () -> () -> configWithZeroRetries, // ConfigurationPortFactory → ConfigurationPort → StartConfiguration
+ lockFile -> new MockRunLockPort(),
+ StartConfigurationValidator::new, // use the real validator
+ jdbcUrl -> new MockSchemaInitializationPort(),
+ (config, lock) -> new MockRunBatchProcessingUseCase(true),
+ SchedulerBatchCommand::new
+ );
+
+ assertEquals(1, runner.run(),
+ "max.retries.transient = 0 must be rejected as invalid startup configuration "
+ + "and must produce exit code 1");
+ }
+
+ /**
+ * Verifies that an invalid boolean property value for logging configuration
+ * (such as a non-boolean log.ai.sensitive value) causes a ConfigurationLoadingException
+ * during the loading phase, which must propagate to exit code 1.
+ * This ensures that malformed boolean configuration values prevent the batch from starting.
+ */
+ @Test
+ void run_returnsOneWhenConfigurationLoadingFailsDueToInvalidBooleanProperty() {
+ BootstrapRunner runner = new BootstrapRunner(
+ () -> {
+ throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException(
+ "Invalid value for log.ai.sensitive: 'maybe'. "
+ + "Must be either 'true' or 'false' (case-insensitive).");
+ },
+ lockFile -> new MockRunLockPort(),
+ StartConfigurationValidator::new,
+ jdbcUrl -> new MockSchemaInitializationPort(),
+ (config, lock) -> new MockRunBatchProcessingUseCase(true),
+ SchedulerBatchCommand::new
+ );
+
+ assertEquals(1, runner.run(),
+ "Invalid boolean property value for logging configuration must produce exit code 1 "
+ + "via ConfigurationLoadingException");
+ }
+
/**
* Hard startup failure test — schema initialization failure must be treated as
* a startup error and result in exit code 1. This verifies that