1
0

M3-AP-005: Batchlauf im Use-Case integriert und sauber von Bootstrap

entkoppelt
This commit is contained in:
2026-04-01 20:34:15 +02:00
parent c482b20df9
commit d60d050948
3 changed files with 413 additions and 80 deletions

View File

@@ -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}.
* <p>
* Verifies correct orchestration of the M2 batch cycle including lock management
* and controlled execution flow.
* Covers:
* <ul>
* <li>Lock acquisition and release lifecycle (M2)</li>
* <li>M3 source folder scanning and per-document processing loop</li>
* <li>M3 happy path: candidate passes pre-checks, ends controlled without KI or target copy</li>
* <li>M3 deterministic content errors: no usable text, page limit exceeded</li>
* <li>M3 technical extraction errors: controlled per-document end, batch continues</li>
* <li>Source folder access failure: batch fails with FAILURE outcome</li>
* </ul>
*/
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<SourceDocumentCandidate> candidates = List.of(
makeCandidate("a.pdf"),
makeCandidate("b.pdf"),
makeCandidate("c.pdf")
);
PdfExtractionSuccess success = new PdfExtractionSuccess("Invoice content", new PdfPageCount(2));
FixedCandidatesPort candidatesPort = new FixedCandidatesPort(candidates);
FixedExtractionPort extractionPort = new FixedExtractionPort(success);
M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase(
config, lockPort, candidatesPort, extractionPort);
BatchRunContext context = new BatchRunContext(new RunId("m3-multi"), Instant.now());
BatchRunOutcome outcome = useCase.execute(context);
assertTrue(outcome.isSuccess(), "All three candidates processed should yield SUCCESS");
assertEquals(3, extractionPort.callCount(), "Extraction should be called once per candidate");
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
@@ -108,9 +290,9 @@ class M2BatchRunProcessingUseCaseTest {
Path sourceDir = Files.createDirectories(tempDir.resolve("source"));
Path targetDir = Files.createDirectories(tempDir.resolve("target"));
Path dbFile = tempDir.resolve("db.sqlite");
Files.createFile(dbFile);
if (!Files.exists(dbFile)) Files.createFile(dbFile);
Path promptFile = tempDir.resolve("prompt.txt");
Files.createFile(promptFile);
if (!Files.exists(promptFile)) Files.createFile(promptFile);
return new StartConfiguration(
sourceDir,
@@ -119,8 +301,8 @@ class M2BatchRunProcessingUseCaseTest {
URI.create("https://api.example.com"),
"gpt-4",
30,
3,
100,
3, // maxRetries
3, // maxPages (low limit useful for page-limit tests)
50000,
promptFile,
tempDir.resolve("lock.lock"),
@@ -130,6 +312,10 @@ class M2BatchRunProcessingUseCaseTest {
);
}
private static SourceDocumentCandidate makeCandidate(String filename) {
return new SourceDocumentCandidate(filename, 1024L, new SourceDocumentLocator("/tmp/" + filename));
}
// -------------------------------------------------------------------------
// Mock / Stub implementations
// -------------------------------------------------------------------------
@@ -140,45 +326,31 @@ class M2BatchRunProcessingUseCaseTest {
private boolean releaseCalled = false;
@Override
public void acquire() {
acquireCalled = true;
}
public void acquire() { acquireCalled = true; }
@Override
public void release() {
releaseCalled = true;
}
public void release() { releaseCalled = true; }
boolean wasAcquireCalled() { return acquireCalled; }
boolean wasReleaseCalled() { return releaseCalled; }
}
/**
* Counting lock port optionally fails on acquire.
* Tracks exact call counts so tests can assert that release() was never called
* when acquire() threw.
*/
/** Counting lock port optionally fails on acquire. */
private static class CountingRunLockPort implements RunLockPort {
private final boolean failOnAcquire;
private int acquireCount = 0;
private int releaseCount = 0;
CountingRunLockPort(boolean failOnAcquire) {
this.failOnAcquire = failOnAcquire;
}
CountingRunLockPort(boolean failOnAcquire) { this.failOnAcquire = failOnAcquire; }
@Override
public void acquire() {
acquireCount++;
if (failOnAcquire) {
throw new RunLockUnavailableException("Another instance already running");
}
if (failOnAcquire) throw new RunLockUnavailableException("Another instance already running");
}
@Override
public void release() {
releaseCount++;
}
public void release() { releaseCount++; }
int acquireCallCount() { return acquireCount; }
int releaseCallCount() { return releaseCount; }
@@ -190,16 +362,58 @@ class M2BatchRunProcessingUseCaseTest {
private boolean releaseCalled = false;
@Override
public void acquire() {
acquireCalled = true;
}
public void acquire() { acquireCalled = true; }
@Override
public void release() {
releaseCalled = true;
}
public void release() { releaseCalled = true; }
boolean wasAcquireCalled() { return acquireCalled; }
boolean wasReleaseCalled() { return releaseCalled; }
}
/** Returns an empty candidate list. */
private static class EmptyCandidatesPort implements SourceDocumentCandidatesPort {
@Override
public List<SourceDocumentCandidate> loadCandidates() {
return List.of();
}
}
/** Returns a fixed list of candidates. */
private static class FixedCandidatesPort implements SourceDocumentCandidatesPort {
private final List<SourceDocumentCandidate> candidates;
FixedCandidatesPort(List<SourceDocumentCandidate> candidates) {
this.candidates = candidates;
}
@Override
public List<SourceDocumentCandidate> loadCandidates() {
return candidates;
}
}
/** Returns a fixed extraction result for any candidate; counts calls. */
private static class FixedExtractionPort implements PdfTextExtractionPort {
private final PdfExtractionResult result;
private int calls = 0;
FixedExtractionPort(PdfExtractionResult result) { this.result = result; }
@Override
public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) {
calls++;
return result;
}
int callCount() { return calls; }
}
/** No-op extraction port that should never be called in tests that use EmptyCandidatesPort. */
private static class NoOpExtractionPort implements PdfTextExtractionPort {
@Override
public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) {
throw new UnsupportedOperationException("Should not be called");
}
}
}