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