M3-AP-005: Batchlauf im Use-Case integriert und sauber von Bootstrap
entkoppelt
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user