diff --git a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/pdfextraction/PdfTextExtractionPortAdapterTest.java b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/pdfextraction/PdfTextExtractionPortAdapterTest.java index f1d5927..b7387ea 100644 --- a/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/pdfextraction/PdfTextExtractionPortAdapterTest.java +++ b/pdf-umbenenner-adapter-out/src/test/java/de/gecheckt/pdf/umbenenner/adapter/outbound/pdfextraction/PdfTextExtractionPortAdapterTest.java @@ -15,6 +15,7 @@ import java.nio.file.Files; import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -125,6 +126,28 @@ class PdfTextExtractionPortAdapterTest { assertNotNull(success.extractedText()); // May be empty, but not null } + @Test + void testCorruptedPdfReturnsTechnicalError() throws Exception { + // Create a file with binary garbage content – exists on disk but is not a valid PDF + Path corruptFile = tempDir.resolve("corrupt.pdf"); + Files.write(corruptFile, "THIS IS NOT A PDF - RANDOM GARBAGE XYZ123!@#".getBytes()); + + SourceDocumentCandidate candidate = new SourceDocumentCandidate( + "corrupt.pdf", + Files.size(corruptFile), + new SourceDocumentLocator(corruptFile.toAbsolutePath().toString()) + ); + + PdfExtractionResult result = adapter.extractTextAndPageCount(candidate); + + // PDFBox cannot load a garbage file; the adapter must catch this and return TechnicalError + assertInstanceOf(PdfExtractionTechnicalError.class, result, + "Binary garbage file (not a real PDF) must yield TechnicalError, not Success"); + PdfExtractionTechnicalError error = (PdfExtractionTechnicalError) result; + assertNotNull(error.errorMessage(), "TechnicalError must carry a non-null error message"); + assertFalse(error.errorMessage().isBlank(), "TechnicalError message must not be blank"); + } + // --- Helper methods to create test PDFs --- /** 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 dd2d560..8f344ed 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 @@ -24,7 +24,9 @@ import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -258,6 +260,45 @@ class M2BatchRunProcessingUseCaseTest { assertTrue(lockPort.wasReleaseCalled(), "Lock should be released even when source access fails"); } + /** + * Mixed-batch test: one document per M3 outcome type in a single run. + * Proves that no individual outcome aborts the overall batch (AP-008 explicit contract). + */ + @Test + void execute_m3MixedBatch_allOutcomeTypes_batchOverallSucceeds() throws Exception { + MockRunLockPort lockPort = new MockRunLockPort(); + // maxPages=3 in buildConfig; pageLimitCandidate has 10 pages → exceeds limit + StartConfiguration config = buildConfig(tempDir); + + SourceDocumentCandidate goodCandidate = makeCandidate("good.pdf"); + SourceDocumentCandidate noTextCandidate = makeCandidate("notext.pdf"); + SourceDocumentCandidate pageLimitCandidate = makeCandidate("toobig.pdf"); + SourceDocumentCandidate technicalErrorCandidate = makeCandidate("broken.pdf"); + SourceDocumentCandidate contentErrorCandidate = makeCandidate("encrypted.pdf"); + + FixedCandidatesPort candidatesPort = new FixedCandidatesPort(List.of( + goodCandidate, noTextCandidate, pageLimitCandidate, + technicalErrorCandidate, contentErrorCandidate)); + + MappedExtractionPort extractionPort = new MappedExtractionPort() + .with(goodCandidate, new PdfExtractionSuccess("Invoice text", new PdfPageCount(1))) + .with(noTextCandidate, new PdfExtractionSuccess(" ", new PdfPageCount(1))) + .with(pageLimitCandidate, new PdfExtractionSuccess("Some text", new PdfPageCount(10))) + .with(technicalErrorCandidate, new PdfExtractionTechnicalError("I/O error", null)) + .with(contentErrorCandidate, new PdfExtractionContentError("PDF is encrypted")); + + M2BatchRunProcessingUseCase useCase = new M2BatchRunProcessingUseCase( + config, lockPort, candidatesPort, extractionPort); + BatchRunContext context = new BatchRunContext(new RunId("m3-mixed"), Instant.now()); + + BatchRunOutcome outcome = useCase.execute(context); + + assertTrue(outcome.isSuccess(), + "Mixed batch with all M3 outcome types must yield batch SUCCESS"); + assertEquals(5, extractionPort.callCount(), + "Extraction must be attempted for each of the 5 candidates"); + } + @Test void execute_m3MultipleCandidates_allProcessed_batchSucceeds() throws Exception { MockRunLockPort lockPort = new MockRunLockPort(); @@ -416,4 +457,27 @@ class M2BatchRunProcessingUseCaseTest { throw new UnsupportedOperationException("Should not be called"); } } + + /** Per-candidate extraction port: maps each candidate to a fixed result; counts calls. */ + private static class MappedExtractionPort implements PdfTextExtractionPort { + private final Map resultMap = new LinkedHashMap<>(); + private int calls = 0; + + MappedExtractionPort with(SourceDocumentCandidate candidate, PdfExtractionResult result) { + resultMap.put(candidate, result); + return this; + } + + @Override + public PdfExtractionResult extractTextAndPageCount(SourceDocumentCandidate candidate) { + calls++; + PdfExtractionResult result = resultMap.get(candidate); + if (result == null) { + throw new IllegalStateException("No extraction result mapped for candidate: " + candidate); + } + return result; + } + + int callCount() { return calls; } + } }