diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCase.java index a45bf84..378f355 100644 --- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCase.java +++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCase.java @@ -3,41 +3,56 @@ package de.gecheckt.pdf.umbenenner.application.usecase; import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration; import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome; import de.gecheckt.pdf.umbenenner.application.port.in.RunBatchProcessingUseCase; +import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort; import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException; +import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException; +import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort; +import de.gecheckt.pdf.umbenenner.application.service.M3PreCheckEvaluator; import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext; +import de.gecheckt.pdf.umbenenner.domain.model.M3PreCheckFailed; +import de.gecheckt.pdf.umbenenner.domain.model.M3PreCheckPassed; +import de.gecheckt.pdf.umbenenner.domain.model.M3ProcessingDecision; +import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError; +import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult; +import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess; +import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError; +import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.util.List; + /** - * M2 implementation of {@link RunBatchProcessingUseCase}. + * M3 batch processing implementation of {@link RunBatchProcessingUseCase}. *
- * This use case orchestrates the batch processing workflow with start protection - * and controlled execution lifecycle, but without actual document processing. - *
- * Responsibilities: - *
+ * M3 processing boundary: + *
- * M2 Non-Goals (not implemented): + * M3 Non-Goals (not implemented): *
* The configuration is loaded and validated by Bootstrap before use case creation;
* the use case receives the result directly and does not re-read it.
*
* @param configuration the validated startup configuration
* @param runLockPort for exclusive run locking
+ * @param sourceDocumentCandidatesPort for loading PDF candidates from the source folder
+ * @param pdfTextExtractionPort for extracting text and page count from a single PDF
* @throws NullPointerException if any parameter is null
*/
- public M2BatchRunProcessingUseCase(StartConfiguration configuration, RunLockPort runLockPort) {
+ public M2BatchRunProcessingUseCase(
+ StartConfiguration configuration,
+ RunLockPort runLockPort,
+ SourceDocumentCandidatesPort sourceDocumentCandidatesPort,
+ PdfTextExtractionPort pdfTextExtractionPort) {
this.configuration = configuration;
this.runLockPort = runLockPort;
+ this.sourceDocumentCandidatesPort = sourceDocumentCandidatesPort;
+ this.pdfTextExtractionPort = pdfTextExtractionPort;
}
@Override
public BatchRunOutcome execute(BatchRunContext context) {
- LOG.info("M2 batch processing initiated with RunId: {}", context.runId());
+ LOG.info("Batch processing initiated. RunId: {}", context.runId());
boolean lockAcquired = false;
try {
@@ -77,18 +102,28 @@ public class M2BatchRunProcessingUseCase implements RunBatchProcessingUseCase {
return BatchRunOutcome.LOCK_UNAVAILABLE;
}
- // Step 2: M2 Batch execution frame (no document processing)
LOG.debug("Configuration in use: source={}, target={}", configuration.sourceFolder(), configuration.targetFolder());
- LOG.info("Batch execution frame initialized - RunId: {}, Start: {}", context.runId(), context.startInstant());
+ LOG.info("Batch run started. RunId: {}, Start: {}", context.runId(), context.startInstant());
- // M2 Non-goal: No source folder scanning, PDF processing, persistence, or filename generation
- // This is a controlled no-op batch cycle that validates the entire orchestration path.
+ // Step 2: Load PDF candidates from source folder
+ List
+ * M3 processing steps per document:
+ *
+ * Per-document errors (extraction failure, pre-check failure) do not abort the overall
+ * batch run. Each candidate ends controlled regardless of its outcome.
+ *
+ * M3 processing boundary: no KI call, no persistence, no filename generation,
+ * no target file copy is initiated here, even for candidates that pass all pre-checks.
+ *
+ * @param candidate the candidate to process
+ */
+ private void processCandidate(SourceDocumentCandidate candidate) {
+ PdfExtractionResult extractionResult = pdfTextExtractionPort.extractTextAndPageCount(candidate);
+
+ switch (extractionResult) {
+ case PdfExtractionSuccess success -> {
+ M3ProcessingDecision decision = M3PreCheckEvaluator.evaluate(candidate, success, configuration);
+ switch (decision) {
+ case M3PreCheckPassed passed ->
+ LOG.info("M3 pre-checks passed for '{}'. Candidate ready for further processing (M4+).",
+ candidate.uniqueIdentifier());
+ case M3PreCheckFailed failed ->
+ LOG.info("M3 pre-check failed for '{}': {}",
+ candidate.uniqueIdentifier(), failed.failureReason());
+ }
+ }
+ case PdfExtractionContentError contentError ->
+ LOG.info("PDF content not extractable for '{}': {}",
+ candidate.uniqueIdentifier(), contentError.reason());
+ case PdfExtractionTechnicalError technicalError ->
+ LOG.warn("Technical error extracting PDF '{}': {}",
+ candidate.uniqueIdentifier(), technicalError.errorMessage());
+ }
+ }
}
diff --git a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCaseTest.java b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCaseTest.java
index fb13ad9..dd2d560 100644
--- a/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCaseTest.java
+++ b/pdf-umbenenner-application/src/test/java/de/gecheckt/pdf/umbenenner/application/usecase/M2BatchRunProcessingUseCaseTest.java
@@ -2,10 +2,20 @@ package de.gecheckt.pdf.umbenenner.application.usecase;
import de.gecheckt.pdf.umbenenner.application.config.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
+import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockUnavailableException;
+import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentAccessException;
+import de.gecheckt.pdf.umbenenner.application.port.out.SourceDocumentCandidatesPort;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
+import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError;
+import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult;
+import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
+import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionTechnicalError;
+import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
+import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
+import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -14,26 +24,39 @@ import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
+import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Tests for {@link M2BatchRunProcessingUseCase}.
*
- * Verifies correct orchestration of the M2 batch cycle including lock management
- * and controlled execution flow.
+ * Covers:
+ *
- * Receives the already-loaded and validated {@link StartConfiguration} so the use case
- * does not need to re-read the configuration file.
+ * Receives the already-loaded and validated {@link StartConfiguration} and run lock port.
+ *
+ * Note: The use case signature may accept additional ports for M3+ functionality,
+ * but bootstrap provides No-Op implementations for now (AP-005 scope).
+ * Full M3 adapter wiring will be completed in AP-007 (Bootstrap expansion).
*/
@FunctionalInterface
public interface UseCaseFactory {
@@ -94,7 +99,10 @@ public class BootstrapRunner {
this.configPortFactory = PropertiesConfigurationPortAdapter::new;
this.runLockPortFactory = FilesystemRunLockPortAdapter::new;
this.validatorFactory = StartConfigurationValidator::new;
- this.useCaseFactory = (config, lock) -> new M2BatchRunProcessingUseCase(config, lock);
+ // AP-005: Use case accepts M3 ports, but bootstrap provides No-Op implementations (M2 scope)
+ // AP-007 will wire real M3 adapters; for now, M2 uses No-Op ports
+ this.useCaseFactory = (config, lock) ->
+ new M2BatchRunProcessingUseCase(config, lock, new NoOpSourceCandidatesPort(), new NoOpExtractionPort());
this.commandFactory = SchedulerBatchCommand::new;
}
@@ -157,6 +165,8 @@ public class BootstrapRunner {
// Step 6: Create the use case with the validated config and run lock (application layer)
// Config is passed directly; the use case does not re-read the properties file.
+ // Note: The use case signature includes M3 ports, but bootstrap (M2 scope) provides No-Op implementations.
+ // Real M3 adapter wiring will be completed in AP-007.
RunBatchProcessingUseCase useCase = useCaseFactory.create(config, runLockPort);
// Step 7: Create the CLI command adapter with the use case
@@ -192,4 +202,36 @@ public class BootstrapRunner {
return 1;
}
}
+
+ // =========================================================================
+ // AP-005 (M2 scope): No-Op port implementations
+ // (Real M3 adapters will be wired in AP-007)
+ // =========================================================================
+
+ /**
+ * No-Op implementation of {@link SourceDocumentCandidatesPort} for M2 scope.
+ *
+ * M2 batch execution does not scan the source folder, so this returns an empty list.
+ * AP-007 will replace this with a real filesystem adapter.
+ */
+ private static class NoOpSourceCandidatesPort implements SourceDocumentCandidatesPort {
+ @Override
+ public java.util.List
+ * M2 batch execution does not extract PDF content, so this port is never called.
+ * AP-007 will replace this with a real PDFBox adapter.
+ */
+ private static class NoOpExtractionPort implements PdfTextExtractionPort {
+ @Override
+ public de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionResult extractTextAndPageCount(
+ de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate) {
+ throw new UnsupportedOperationException("M2 scope: No-Op port, should not be called");
+ }
+ }
}
\ No newline at end of file
+ *
+ *
+ *
*/
class M2BatchRunProcessingUseCaseTest {
@TempDir
Path tempDir;
+ // -------------------------------------------------------------------------
+ // M2: Lock lifecycle tests (preserved, updated constructor)
+ // -------------------------------------------------------------------------
+
@Test
void execute_successfullyAcquiresAndReleasesLock() throws Exception {
MockRunLockPort lockPort = new MockRunLockPort();
StartConfiguration config = buildConfig(tempDir);
- M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(config, lockPort);
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort());
BatchRunContext context = new BatchRunContext(new RunId("test-run-1"), Instant.now());
BatchRunOutcome outcome = useCase.execute(context);
@@ -48,7 +71,8 @@ class M2BatchRunProcessingUseCaseTest {
CountingRunLockPort lockPort = new CountingRunLockPort(true);
StartConfiguration config = buildConfig(tempDir);
- M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(config, lockPort);
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort());
BatchRunContext context = new BatchRunContext(new RunId("test-run-2"), Instant.now());
BatchRunOutcome outcome = useCase.execute(context);
@@ -60,46 +84,204 @@ class M2BatchRunProcessingUseCaseTest {
/**
* Regression test for M2-F1: when acquire() fails, release() must NOT be called.
- * Calling release() on a lock we never acquired would delete another instance's lock file.
*/
@Test
void execute_doesNotReleaseLockWhenAcquireFails() throws Exception {
CountingRunLockPort lockPort = new CountingRunLockPort(true);
StartConfiguration config = buildConfig(tempDir);
- M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(config, lockPort);
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort());
BatchRunContext context = new BatchRunContext(new RunId("test-run-f1"), Instant.now());
useCase.execute(context);
assertEquals(1, lockPort.acquireCallCount(), "acquire() should be called exactly once");
assertEquals(0, lockPort.releaseCallCount(),
- "release() must NOT be called when acquire() failed – doing so would delete another instance's lock file");
+ "release() must NOT be called when acquire() failed – would delete another instance's lock file");
}
@Test
void execute_releasesLockEvenOnUnexpectedError() throws Exception {
- // Lock acquires successfully, but an unexpected exception occurs after that.
- // The lock must still be released.
ErrorAfterAcquireLockPort lockPort = new ErrorAfterAcquireLockPort();
StartConfiguration config = buildConfig(tempDir);
- // Use a configuration that triggers an NPE internally – simulate by passing null configuration
- // Instead: use a use case subclass that throws after acquire, or use a custom port.
- // Here we verify via a use case that fails after acquiring the lock.
- M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(config, lockPort);
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort());
BatchRunContext context = new BatchRunContext(new RunId("test-run-3"), Instant.now());
BatchRunOutcome outcome = useCase.execute(context);
- // Lock was acquired (no exception thrown by acquire) so release must be called
assertTrue(lockPort.wasAcquireCalled(), "Lock acquire should be called");
assertTrue(lockPort.wasReleaseCalled(), "Lock should be released even after unexpected error");
- // The use case itself completes normally since the config is valid;
- // this test primarily guards the finally-block path for the acquired case.
assertTrue(outcome.isSuccess() || outcome.isFailure());
}
+ // -------------------------------------------------------------------------
+ // M3: Source folder scanning and candidate processing
+ // -------------------------------------------------------------------------
+
+ @Test
+ void execute_withNoCandidates_returnsSuccess() throws Exception {
+ MockRunLockPort lockPort = new MockRunLockPort();
+ StartConfiguration config = buildConfig(tempDir);
+
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort());
+ BatchRunContext context = new BatchRunContext(new RunId("m3-empty"), Instant.now());
+
+ BatchRunOutcome outcome = useCase.execute(context);
+
+ assertTrue(outcome.isSuccess(), "Empty candidate list should still yield SUCCESS");
+ }
+
+ @Test
+ void execute_m3HappyPath_candidatePassesPreChecks_endsControlledWithoutKiOrCopy() throws Exception {
+ MockRunLockPort lockPort = new MockRunLockPort();
+ StartConfiguration config = buildConfig(tempDir);
+
+ // Candidate with usable text within page limit
+ SourceDocumentCandidate candidate = makeCandidate("document.pdf");
+ PdfExtractionSuccess success = new PdfExtractionSuccess("Invoice text", new PdfPageCount(1));
+ FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
+ FixedExtractionPort extractionPort = new FixedExtractionPort(success);
+
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, candidatesPort, extractionPort);
+ BatchRunContext context = new BatchRunContext(new RunId("m3-happy"), Instant.now());
+
+ BatchRunOutcome outcome = useCase.execute(context);
+
+ // Batch run succeeds; document ended controlled at M3 boundary (no KI, no copy)
+ assertTrue(outcome.isSuccess(), "M3 happy path should yield SUCCESS");
+ assertEquals(1, extractionPort.callCount(), "Extraction should be called exactly once");
+ }
+
+ @Test
+ void execute_m3NoUsableText_candidateEndsControlled_batchContinues() throws Exception {
+ MockRunLockPort lockPort = new MockRunLockPort();
+ StartConfiguration config = buildConfig(tempDir);
+
+ SourceDocumentCandidate candidate = makeCandidate("image-only.pdf");
+ // Extraction returns text with no letters or digits
+ PdfExtractionSuccess emptySuccess = new PdfExtractionSuccess(" ", new PdfPageCount(1));
+ FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
+ FixedExtractionPort extractionPort = new FixedExtractionPort(emptySuccess);
+
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, candidatesPort, extractionPort);
+ BatchRunContext context = new BatchRunContext(new RunId("m3-no-text"), Instant.now());
+
+ BatchRunOutcome outcome = useCase.execute(context);
+
+ // Document ends with pre-check failure; batch itself still succeeds
+ assertTrue(outcome.isSuccess(), "No-usable-text pre-check failure should not abort the batch run");
+ }
+
+ @Test
+ void execute_m3PageLimitExceeded_candidateEndsControlled_batchContinues() throws Exception {
+ MockRunLockPort lockPort = new MockRunLockPort();
+ // Config has maxPages=3; document has 10 pages
+ StartConfiguration config = buildConfig(tempDir);
+
+ SourceDocumentCandidate candidate = makeCandidate("big.pdf");
+ PdfExtractionSuccess manyPages = new PdfExtractionSuccess("Some text", new PdfPageCount(10));
+ FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
+ FixedExtractionPort extractionPort = new FixedExtractionPort(manyPages);
+
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, candidatesPort, extractionPort);
+ BatchRunContext context = new BatchRunContext(new RunId("m3-page-limit"), Instant.now());
+
+ BatchRunOutcome outcome = useCase.execute(context);
+
+ // maxPages in buildConfig is 3; 10 pages exceeds limit – pre-check fails, batch continues
+ assertTrue(outcome.isSuccess(), "Page limit exceeded should not abort the batch run");
+ }
+
+ @Test
+ void execute_m3ExtractionContentError_candidateEndsControlled_batchContinues() throws Exception {
+ MockRunLockPort lockPort = new MockRunLockPort();
+ StartConfiguration config = buildConfig(tempDir);
+
+ SourceDocumentCandidate candidate = makeCandidate("encrypted.pdf");
+ PdfExtractionContentError contentError = new PdfExtractionContentError("PDF is encrypted");
+ FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
+ FixedExtractionPort extractionPort = new FixedExtractionPort(contentError);
+
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, candidatesPort, extractionPort);
+ BatchRunContext context = new BatchRunContext(new RunId("m3-content-error"), Instant.now());
+
+ BatchRunOutcome outcome = useCase.execute(context);
+
+ assertTrue(outcome.isSuccess(), "Extraction content error should not abort the batch run");
+ }
+
+ @Test
+ void execute_m3ExtractionTechnicalError_candidateEndsControlled_batchContinues() throws Exception {
+ MockRunLockPort lockPort = new MockRunLockPort();
+ StartConfiguration config = buildConfig(tempDir);
+
+ SourceDocumentCandidate candidate = makeCandidate("corrupt.pdf");
+ PdfExtractionTechnicalError technicalError = new PdfExtractionTechnicalError("I/O error reading file", null);
+ FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of(candidate));
+ FixedExtractionPort extractionPort = new FixedExtractionPort(technicalError);
+
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, candidatesPort, extractionPort);
+ BatchRunContext context = new BatchRunContext(new RunId("m3-tech-error"), Instant.now());
+
+ BatchRunOutcome outcome = useCase.execute(context);
+
+ assertTrue(outcome.isSuccess(), "Technical extraction error should not abort the batch run");
+ }
+
+ @Test
+ void execute_m3SourceAccessException_returnsFailure() throws Exception {
+ MockRunLockPort lockPort = new MockRunLockPort();
+ StartConfiguration config = buildConfig(tempDir);
+
+ SourceDocumentCandidatesPort failingPort = () -> {
+ throw new SourceDocumentAccessException("Source folder not readable");
+ };
+
+ M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
+ config, lockPort, failingPort, new NoOpExtractionPort());
+ BatchRunContext context = new BatchRunContext(new RunId("m3-access-fail"), Instant.now());
+
+ BatchRunOutcome outcome = useCase.execute(context);
+
+ assertTrue(outcome.isFailure(), "Source folder access failure should yield FAILURE outcome");
+ assertFalse(outcome.isSuccess(), "Source folder access failure must not be SUCCESS");
+ // Lock must still be released
+ assertTrue(lockPort.wasReleaseCalled(), "Lock should be released even when source access fails");
+ }
+
+ @Test
+ void execute_m3MultipleCandidates_allProcessed_batchSucceeds() throws Exception {
+ MockRunLockPort lockPort = new MockRunLockPort();
+ StartConfiguration config = buildConfig(tempDir);
+
+ List