1
0

M6 komplett umgesetzt

This commit is contained in:
2026-04-07 12:26:14 +02:00
parent 506f5ac32e
commit 8bcd80d70a
51 changed files with 5960 additions and 536 deletions

View File

@@ -0,0 +1,317 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiFunctionalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.AiTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckPassed;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link AiNamingService}.
* <p>
* Covers: prompt load failure, AI invocation failure, unparseable response,
* functional validation failure, and the successful naming proposal path.
*/
@ExtendWith(MockitoExtension.class)
class AiNamingServiceTest {
private static final String MODEL_NAME = "gpt-4";
private static final int MAX_CHARS = 1000;
private static final Instant FIXED_INSTANT = Instant.parse("2026-04-07T10:00:00Z");
@Mock
private AiInvocationPort aiInvocationPort;
@Mock
private PromptPort promptPort;
private AiResponseValidator validator;
private AiNamingService service;
private SourceDocumentCandidate candidate;
private PreCheckPassed preCheckPassed;
@BeforeEach
void setUp() {
validator = new AiResponseValidator(() -> FIXED_INSTANT);
service = new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, MAX_CHARS);
candidate = new SourceDocumentCandidate(
"test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
preCheckPassed = new PreCheckPassed(
candidate, new PdfExtractionSuccess("Document text content", new PdfPageCount(2)));
}
// -------------------------------------------------------------------------
// Helper
// -------------------------------------------------------------------------
private static AiRequestRepresentation dummyRequest() {
return new AiRequestRepresentation(
new PromptIdentifier("prompt.txt"), "Prompt content", "Document text", 13);
}
private static AiInvocationSuccess successWith(String jsonBody) {
return new AiInvocationSuccess(dummyRequest(), new AiRawResponse(jsonBody));
}
private static AiInvocationTechnicalFailure technicalFailure(String reason, String message) {
return new AiInvocationTechnicalFailure(dummyRequest(), reason, message);
}
// -------------------------------------------------------------------------
// Prompt load failure
// -------------------------------------------------------------------------
@Test
void invoke_promptLoadFailure_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingFailure("FILE_NOT_FOUND", "Prompt file missing"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
AiTechnicalFailure failure = (AiTechnicalFailure) result;
assertThat(failure.errorMessage()).contains("Prompt loading failed");
assertThat(failure.aiContext().modelName()).isEqualTo(MODEL_NAME);
}
// -------------------------------------------------------------------------
// AI invocation failure
// -------------------------------------------------------------------------
@Test
void invoke_aiInvocationTimeout_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt-v1.txt"), "Analyze this document."));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
technicalFailure("TIMEOUT", "Request timed out after 30s"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
assertThat(((AiTechnicalFailure) result).errorMessage()).contains("TIMEOUT");
}
@Test
void invoke_aiInvocationConnectionError_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt content"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
technicalFailure("CONNECTION_ERROR", "Connection refused"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
}
// -------------------------------------------------------------------------
// Response parsing failure (unparseable JSON → technical failure)
// -------------------------------------------------------------------------
@Test
void invoke_unparseableAiResponse_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("This is not JSON at all"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
assertThat(((AiTechnicalFailure) result).aiContext().aiRawResponse())
.isEqualTo("This is not JSON at all");
}
@Test
void invoke_aiResponseMissingTitle_returnsAiTechnicalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"reasoning\":\"No title provided\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiTechnicalFailure.class);
}
// -------------------------------------------------------------------------
// Functional validation failure (parseable but semantically invalid)
// -------------------------------------------------------------------------
@Test
void invoke_aiResponseTitleTooLong_returnsAiFunctionalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
// 21-char title: "TitleThatIsTooLongXXX"
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"TitleThatIsTooLongXXX\",\"reasoning\":\"Too long\",\"date\":\"2026-01-15\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiFunctionalFailure.class);
}
@Test
void invoke_aiResponseGenericTitle_returnsAiFunctionalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Dokument\",\"reasoning\":\"Generic\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiFunctionalFailure.class);
}
@Test
void invoke_aiResponseInvalidDateFormat_returnsAiFunctionalFailure() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Rechnung\",\"reasoning\":\"OK\",\"date\":\"15.01.2026\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(AiFunctionalFailure.class);
}
// -------------------------------------------------------------------------
// Successful naming proposal
// -------------------------------------------------------------------------
@Test
void invoke_validAiResponse_returnsNamingProposalReady() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt-v1.txt"), "Analyze the document."));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Stromabrechnung\",\"reasoning\":\"Electricity invoice\",\"date\":\"2026-01-15\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(NamingProposalReady.class);
NamingProposalReady ready = (NamingProposalReady) result;
assertThat(ready.proposal().validatedTitle()).isEqualTo("Stromabrechnung");
assertThat(ready.proposal().resolvedDate()).isEqualTo(LocalDate.of(2026, 1, 15));
assertThat(ready.proposal().dateSource()).isEqualTo(DateSource.AI_PROVIDED);
assertThat(ready.aiContext().modelName()).isEqualTo(MODEL_NAME);
assertThat(ready.aiContext().promptIdentifier()).isEqualTo("prompt-v1.txt");
}
@Test
void invoke_validAiResponseWithoutDate_usesFallbackDate() {
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document\"}"));
DocumentProcessingOutcome result = service.invoke(preCheckPassed);
assertThat(result).isInstanceOf(NamingProposalReady.class);
NamingProposalReady ready = (NamingProposalReady) result;
assertThat(ready.proposal().dateSource()).isEqualTo(DateSource.FALLBACK_CURRENT);
assertThat(ready.proposal().resolvedDate())
.isEqualTo(FIXED_INSTANT.atZone(ZoneOffset.UTC).toLocalDate());
}
@Test
void invoke_documentTextLongerThanMax_sendsLimitedText() {
// max chars is 1000, document text is 2000 chars → sent chars should be 1000
String longText = "X".repeat(2000);
PreCheckPassed longDoc = new PreCheckPassed(
candidate, new PdfExtractionSuccess(longText, new PdfPageCount(5)));
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\",\"date\":\"2026-03-01\"}"));
DocumentProcessingOutcome result = service.invoke(longDoc);
assertThat(result).isInstanceOf(NamingProposalReady.class);
NamingProposalReady ready = (NamingProposalReady) result;
assertThat(ready.aiContext().sentCharacterCount()).isEqualTo(MAX_CHARS);
}
@Test
void invoke_documentTextShorterThanMax_sendsFullText() {
String shortText = "Short document";
PreCheckPassed shortDoc = new PreCheckPassed(
candidate, new PdfExtractionSuccess(shortText, new PdfPageCount(1)));
when(promptPort.loadPrompt()).thenReturn(
new PromptLoadingSuccess(new PromptIdentifier("prompt.txt"), "Prompt"));
when(aiInvocationPort.invoke(any(AiRequestRepresentation.class))).thenReturn(
successWith("{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\",\"date\":\"2026-03-01\"}"));
DocumentProcessingOutcome result = service.invoke(shortDoc);
assertThat(result).isInstanceOf(NamingProposalReady.class);
NamingProposalReady ready = (NamingProposalReady) result;
assertThat(ready.aiContext().sentCharacterCount()).isEqualTo(shortText.length());
}
// -------------------------------------------------------------------------
// Null handling
// -------------------------------------------------------------------------
@Test
void invoke_nullPreCheckPassed_throwsNullPointerException() {
assertThatThrownBy(() -> service.invoke(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("preCheckPassed must not be null");
}
@Test
void constructor_nullAiPort_throwsNullPointerException() {
assertThatThrownBy(() -> new AiNamingService(null, promptPort, validator, MODEL_NAME, MAX_CHARS))
.isInstanceOf(NullPointerException.class);
}
@Test
void constructor_nullPromptPort_throwsNullPointerException() {
assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, null, validator, MODEL_NAME, MAX_CHARS))
.isInstanceOf(NullPointerException.class);
}
@Test
void constructor_nullValidator_throwsNullPointerException() {
assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, null, MODEL_NAME, MAX_CHARS))
.isInstanceOf(NullPointerException.class);
}
@Test
void constructor_maxTextCharactersZero_throwsIllegalArgumentException() {
assertThatThrownBy(() -> new AiNamingService(aiInvocationPort, promptPort, validator, MODEL_NAME, 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("maxTextCharacters must be >= 1");
}
}

View File

@@ -0,0 +1,217 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingResult;
import de.gecheckt.pdf.umbenenner.domain.model.AiResponseParsingSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link AiResponseParser}.
* <p>
* Covers structural parsing rules: valid JSON objects, mandatory fields,
* optional date, extra fields, and rejection of non-JSON or mixed-content responses.
*/
class AiResponseParserTest {
// -------------------------------------------------------------------------
// Success cases
// -------------------------------------------------------------------------
@Test
void parse_validJsonWithAllFields_returnsSuccess() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Stromabrechnung\",\"reasoning\":\"Found bill dated 2026-01-15\",\"date\":\"2026-01-15\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.title()).isEqualTo("Stromabrechnung");
assertThat(parsed.reasoning()).isEqualTo("Found bill dated 2026-01-15");
assertThat(parsed.dateString()).contains("2026-01-15");
}
@Test
void parse_validJsonWithoutDate_returnsSuccessWithEmptyOptional() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Kontoauszug\",\"reasoning\":\"No date found in document\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.title()).isEqualTo("Kontoauszug");
assertThat(parsed.dateString()).isEmpty();
}
@Test
void parse_validJsonWithAdditionalFields_toleratesExtraFields() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Rechnung\",\"reasoning\":\"Invoice\",\"confidence\":0.95,\"lang\":\"de\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.title()).isEqualTo("Rechnung");
}
@Test
void parse_validJsonWithLeadingAndTrailingWhitespace_trimsAndSucceeds() {
AiRawResponse raw = new AiRawResponse(
" {\"title\":\"Vertrag\",\"reasoning\":\"Contract document\"} ");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
}
@Test
void parse_emptyReasoningField_isAccepted() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Mahnung\",\"reasoning\":\"\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.reasoning()).isEmpty();
}
@Test
void parse_nullDateField_treatedAsAbsent() {
AiRawResponse raw = new AiRawResponse(
"{\"title\":\"Bescheid\",\"reasoning\":\"Administrative notice\",\"date\":null}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingSuccess.class);
ParsedAiResponse parsed = ((AiResponseParsingSuccess) result).response();
assertThat(parsed.dateString()).isEmpty();
}
// -------------------------------------------------------------------------
// Failure cases structural
// -------------------------------------------------------------------------
@Test
void parse_emptyBody_returnsFailure() {
AiRawResponse raw = new AiRawResponse("");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("EMPTY_RESPONSE");
}
@Test
void parse_blankBody_returnsFailure() {
AiRawResponse raw = new AiRawResponse(" \t\n ");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
}
@Test
void parse_plainText_returnsFailure() {
AiRawResponse raw = new AiRawResponse("Sure, here is the title: Rechnung");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("NOT_JSON_OBJECT");
}
@Test
void parse_jsonEmbeddedInProse_returnsFailure() {
AiRawResponse raw = new AiRawResponse(
"Here is the result: {\"title\":\"Rechnung\",\"reasoning\":\"r\"} Hope that helps!");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("NOT_JSON_OBJECT");
}
@Test
void parse_jsonArray_returnsFailure() {
AiRawResponse raw = new AiRawResponse("[{\"title\":\"Rechnung\",\"reasoning\":\"r\"}]");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("NOT_JSON_OBJECT");
}
@Test
void parse_invalidJson_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"title\":\"Rechnung\",\"reasoning\":}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("INVALID_JSON");
}
@Test
void parse_missingTitle_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"reasoning\":\"Some reasoning without title\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("MISSING_TITLE");
}
@Test
void parse_nullTitle_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"title\":null,\"reasoning\":\"r\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("MISSING_TITLE");
}
@Test
void parse_blankTitle_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"title\":\" \",\"reasoning\":\"r\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("BLANK_TITLE");
}
@Test
void parse_missingReasoning_returnsFailure() {
AiRawResponse raw = new AiRawResponse("{\"title\":\"Rechnung\"}");
AiResponseParsingResult result = AiResponseParser.parse(raw);
assertThat(result).isInstanceOf(AiResponseParsingFailure.class);
assertThat(((AiResponseParsingFailure) result).failureReason())
.isEqualTo("MISSING_REASONING");
}
@Test
void parse_nullRawResponse_throwsNullPointerException() {
assertThatThrownBy(() -> AiResponseParser.parse(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("rawResponse must not be null");
}
}

View File

@@ -0,0 +1,236 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.ParsedAiResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link AiResponseValidator}.
* <p>
* Covers: title character rules, length limit, generic placeholder detection,
* date parsing, date fallback via {@link ClockPort}, and null handling.
*/
class AiResponseValidatorTest {
private static final Instant FIXED_INSTANT = Instant.parse("2026-04-07T10:00:00Z");
private static final LocalDate FIXED_DATE = FIXED_INSTANT.atZone(ZoneOffset.UTC).toLocalDate();
private AiResponseValidator validator;
@BeforeEach
void setUp() {
ClockPort fixedClock = () -> FIXED_INSTANT;
validator = new AiResponseValidator(fixedClock);
}
// -------------------------------------------------------------------------
// Valid cases
// -------------------------------------------------------------------------
@Test
void validate_validTitleAndAiDate_returnsValidWithAiProvided() {
ParsedAiResponse parsed = ParsedAiResponse.of("Stromabrechnung", "Electricity bill", "2026-01-15");
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
NamingProposal proposal = ((AiResponseValidator.AiValidationResult.Valid) result).proposal();
assertThat(proposal.validatedTitle()).isEqualTo("Stromabrechnung");
assertThat(proposal.resolvedDate()).isEqualTo(LocalDate.of(2026, 1, 15));
assertThat(proposal.dateSource()).isEqualTo(DateSource.AI_PROVIDED);
assertThat(proposal.aiReasoning()).isEqualTo("Electricity bill");
}
@Test
void validate_validTitleNoDate_usesFallbackCurrentDate() {
ParsedAiResponse parsed = ParsedAiResponse.of("Kontoauszug", "No date in document", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
NamingProposal proposal = ((AiResponseValidator.AiValidationResult.Valid) result).proposal();
assertThat(proposal.resolvedDate()).isEqualTo(FIXED_DATE);
assertThat(proposal.dateSource()).isEqualTo(DateSource.FALLBACK_CURRENT);
}
@Test
void validate_titleWithUmlauts_isAccepted() {
ParsedAiResponse parsed = ParsedAiResponse.of("Mietvertrag Müller", "Rental contract", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
@Test
void validate_titleWithSzligChar_isAccepted() {
ParsedAiResponse parsed = ParsedAiResponse.of("Straßenrechnung", "Street bill", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
@Test
void validate_titleWithDigits_isAccepted() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung 2026", "Invoice 2026", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
@Test
void validate_titleExactly20Chars_isAccepted() {
String title = "12345678901234567890"; // exactly 20 chars
ParsedAiResponse parsed = ParsedAiResponse.of(title, "test", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
@Test
void validate_emptyReasoning_isAccepted() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung", "", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Valid.class);
}
// -------------------------------------------------------------------------
// Title validation failures
// -------------------------------------------------------------------------
@Test
void validate_title21Chars_returnsInvalid() {
String title = "1234567890123456789A1"; // 21 chars
ParsedAiResponse parsed = ParsedAiResponse.of(title, "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.contains("20");
}
@Test
void validate_titleWithSpecialChar_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung!", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.containsIgnoringCase("disallowed");
}
@Test
void validate_titleWithHyphen_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Strom-Rechnung", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
@Test
void validate_genericTitleDokument_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Dokument", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.containsIgnoringCase("placeholder");
}
@Test
void validate_genericTitleDateiCaseInsensitive_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("DATEI", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
@Test
void validate_genericTitleScan_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("scan", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
@Test
void validate_genericTitlePdf_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("PDF", "reasoning", null);
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
// -------------------------------------------------------------------------
// Date validation failures
// -------------------------------------------------------------------------
@Test
void validate_aiProvidesUnparseableDate_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung", "reasoning", "not-a-date");
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
assertThat(((AiResponseValidator.AiValidationResult.Invalid) result).errorMessage())
.contains("not-a-date");
}
@Test
void validate_aiProvidesWrongDateFormat_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung", "reasoning", "15.01.2026");
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
@Test
void validate_aiProvidesPartialDate_returnsInvalid() {
ParsedAiResponse parsed = ParsedAiResponse.of("Rechnung", "reasoning", "2026-01");
AiResponseValidator.AiValidationResult result = validator.validate(parsed);
assertThat(result).isInstanceOf(AiResponseValidator.AiValidationResult.Invalid.class);
}
// -------------------------------------------------------------------------
// Null handling
// -------------------------------------------------------------------------
@Test
void validate_nullParsedResponse_throwsNullPointerException() {
assertThatThrownBy(() -> validator.validate(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("parsed must not be null");
}
@Test
void constructor_nullClockPort_throwsNullPointerException() {
assertThatThrownBy(() -> new AiResponseValidator(null))
.isInstanceOf(NullPointerException.class)
.hasMessage("clockPort must not be null");
}
}

View File

@@ -13,10 +13,22 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceLookupTechnica
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.domain.model.AiAttemptContext;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentProcessingOutcome;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposal;
import de.gecheckt.pdf.umbenenner.domain.model.NamingProposalReady;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionSuccess;
import de.gecheckt.pdf.umbenenner.domain.model.PdfPageCount;
import de.gecheckt.pdf.umbenenner.domain.model.PreCheckFailed;
@@ -32,6 +44,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
@@ -72,7 +85,8 @@ class DocumentProcessingCoordinatorTest {
recordRepo = new CapturingDocumentRecordRepository();
attemptRepo = new CapturingProcessingAttemptRepository();
unitOfWorkPort = new CapturingUnitOfWorkPort(recordRepo, attemptRepo);
processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, new NoOpProcessingLogger());
processor = new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger());
candidate = new SourceDocumentCandidate(
"test.pdf", 1024L, new SourceDocumentLocator("/tmp/test.pdf"));
@@ -86,17 +100,16 @@ class DocumentProcessingCoordinatorTest {
// -------------------------------------------------------------------------
@Test
void process_newDocument_preCheckPassed_persistsSuccessStatus() {
void process_newDocument_namingProposalReady_persistsProposalReadyStatus() {
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
processor.process(candidate, fingerprint, outcome, context, attemptStart);
// One attempt written
assertEquals(1, attemptRepo.savedAttempts.size());
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
assertEquals(ProcessingStatus.SUCCESS, attempt.status());
assertEquals(ProcessingStatus.PROPOSAL_READY, attempt.status());
assertFalse(attempt.retryable());
assertNull(attempt.failureClass());
assertNull(attempt.failureMessage());
@@ -104,10 +117,11 @@ class DocumentProcessingCoordinatorTest {
// One master record created
assertEquals(1, recordRepo.createdRecords.size());
DocumentRecord record = recordRepo.createdRecords.get(0);
assertEquals(ProcessingStatus.SUCCESS, record.overallStatus());
assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus());
assertEquals(0, record.failureCounters().contentErrorCount());
assertEquals(0, record.failureCounters().transientErrorCount());
assertNotNull(record.lastSuccessInstant());
// lastSuccessInstant is null in M5; it is set by the target-copy stage (M6)
assertNull(record.lastSuccessInstant());
assertNull(record.lastFailureInstant());
}
@@ -203,24 +217,24 @@ class DocumentProcessingCoordinatorTest {
}
@Test
void process_knownDocument_preCheckPassed_persistsSuccess() {
void process_knownDocument_namingProposalReady_persistsProposalReadyStatus() {
DocumentRecord existingRecord = buildRecord(
ProcessingStatus.FAILED_RETRYABLE,
new FailureCounters(0, 1));
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
processor.process(candidate, fingerprint, outcome, context, attemptStart);
assertEquals(1, recordRepo.updatedRecords.size());
DocumentRecord record = recordRepo.updatedRecords.get(0);
assertEquals(ProcessingStatus.SUCCESS, record.overallStatus());
// Counters unchanged on success
assertEquals(ProcessingStatus.PROPOSAL_READY, record.overallStatus());
// Counters unchanged on naming proposal success
assertEquals(0, record.failureCounters().contentErrorCount());
assertEquals(1, record.failureCounters().transientErrorCount());
assertNotNull(record.lastSuccessInstant());
// lastSuccessInstant is null in M5; it is set by the target-copy stage (M6)
assertNull(record.lastSuccessInstant());
}
// -------------------------------------------------------------------------
@@ -469,8 +483,7 @@ class DocumentProcessingCoordinatorTest {
// -------------------------------------------------------------------------
@Test
void process_newDocument_firstContentError_failureMessageContainsContentErrorCount() {
// Prüft, dass die Fehlermeldung die Fehleranzahl enthält (nicht leer ist)
void process_newDocument_firstContentError_failureMessageContainsFailureReason() {
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckFailed(
candidate, PreCheckFailureReason.NO_USABLE_TEXT);
@@ -481,13 +494,13 @@ class DocumentProcessingCoordinatorTest {
assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_RETRYABLE");
assertFalse(attempt.failureMessage().isBlank(),
"Fehlermeldung darf nicht leer sein bei FAILED_RETRYABLE");
assertTrue(attempt.failureMessage().contains("ContentErrors=1"),
"Fehlermeldung muss den Inhaltsfehler-Zähler enthalten: " + attempt.failureMessage());
assertTrue(attempt.failureMessage().contains("No usable text in extracted PDF content"),
"Fehlermeldung muss den Fehlergrund enthalten: " + attempt.failureMessage());
}
@Test
void process_knownDocument_secondContentError_failureMessageContainsFinalStatus() {
// Prüft, dass die Fehlermeldung bei FAILED_FINAL den Endzustand enthält
// Prüft, dass die Fehlermeldung bei FAILED_FINAL den Fehlergrund enthält
DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_RETRYABLE, new FailureCounters(1, 0));
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckFailed(
@@ -499,13 +512,12 @@ class DocumentProcessingCoordinatorTest {
assertNotNull(attempt.failureMessage(), "Fehlermeldung darf nicht null sein bei FAILED_FINAL");
assertFalse(attempt.failureMessage().isBlank(),
"Fehlermeldung darf nicht leer sein bei FAILED_FINAL");
assertTrue(attempt.failureMessage().contains("ContentErrors=2"),
"Fehlermeldung muss den aktualisierten Inhaltsfehler-Zähler enthalten: " + attempt.failureMessage());
assertTrue(attempt.failureMessage().contains("Document page count exceeds configured limit"),
"Fehlermeldung muss den Fehlergrund enthalten: " + attempt.failureMessage());
}
@Test
void process_newDocument_technicalError_failureMessageContainsTransientErrorCount() {
// Prüft, dass die Fehlermeldung bei transientem Fehler den Transient-Zähler enthält
void process_newDocument_technicalError_failureMessageContainsTechnicalDetail() {
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new TechnicalDocumentError(candidate, "Timeout", null);
@@ -513,22 +525,21 @@ class DocumentProcessingCoordinatorTest {
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
assertNotNull(attempt.failureMessage());
assertTrue(attempt.failureMessage().contains("TransientErrors=1"),
"Fehlermeldung muss den Transient-Fehler-Zähler enthalten: " + attempt.failureMessage());
assertTrue(attempt.failureMessage().contains("Timeout"),
"Fehlermeldung muss den technischen Fehlerdetail enthalten: " + attempt.failureMessage());
}
@Test
void process_newDocument_preCheckPassed_failureClassAndMessageAreNull() {
// Prüft, dass bei Erfolg failureClass und failureMessage null sind
void process_newDocument_namingProposalReady_failureClassAndMessageAreNull() {
// Prüft, dass bei PROPOSAL_READY failureClass und failureMessage null sind
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
processor.process(candidate, fingerprint, outcome, context, attemptStart);
ProcessingAttempt attempt = attemptRepo.savedAttempts.get(0);
assertNull(attempt.failureClass(), "Bei Erfolg muss failureClass null sein");
assertNull(attempt.failureMessage(), "Bei Erfolg muss failureMessage null sein");
assertNull(attempt.failureClass(), "Bei PROPOSAL_READY muss failureClass null sein");
assertNull(attempt.failureMessage(), "Bei PROPOSAL_READY muss failureMessage null sein");
}
// -------------------------------------------------------------------------
@@ -536,9 +547,9 @@ class DocumentProcessingCoordinatorTest {
// -------------------------------------------------------------------------
@Test
void process_knownDocument_preCheckPassed_lastSuccessInstantSetAndLastFailureInstantFromPreviousRecord() {
// Prüft, dass bei SUCCESS am known-Dokument lastSuccessInstant gesetzt
// und lastFailureInstant aus dem Vorgänger-Datensatz übernommen wird
void process_knownDocument_namingProposalReady_lastSuccessInstantNullAndLastFailureInstantFromPreviousRecord() {
// Prüft, dass bei PROPOSAL_READY am known-Dokument lastSuccessInstant null bleibt
// (M6 setzt ihn erst nach der Zielkopie) und lastFailureInstant aus dem Vorgänger übernommen wird
Instant previousFailureInstant = Instant.parse("2025-01-15T10:00:00Z");
DocumentRecord existingRecord = new DocumentRecord(
fingerprint,
@@ -549,19 +560,20 @@ class DocumentProcessingCoordinatorTest {
previousFailureInstant, // lastFailureInstant vorhanden
null, // noch kein Erfolgszeitpunkt
Instant.now(),
Instant.now()
Instant.now(),
null,
null
);
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
DocumentProcessingOutcome outcome = buildNamingProposalOutcome();
processor.process(candidate, fingerprint, outcome, context, attemptStart);
DocumentRecord updated = recordRepo.updatedRecords.get(0);
assertNotNull(updated.lastSuccessInstant(),
"lastSuccessInstant muss nach erfolgreichem Verarbeiten gesetzt sein");
assertNull(updated.lastSuccessInstant(),
"lastSuccessInstant muss nach PROPOSAL_READY null bleiben (wird erst von M6 gesetzt)");
assertEquals(previousFailureInstant, updated.lastFailureInstant(),
"lastFailureInstant muss bei SUCCESS den Vorgänger-Wert beibehalten");
"lastFailureInstant muss bei PROPOSAL_READY den Vorgänger-Wert beibehalten");
}
@Test
@@ -578,7 +590,9 @@ class DocumentProcessingCoordinatorTest {
null, // noch keine Fehlzeit
previousSuccessInstant, // vorheriger Erfolg vorhanden
Instant.now(),
Instant.now()
Instant.now(),
null,
null
);
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckFailed(
@@ -602,7 +616,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass bei Lookup-Fehler ein Fehler-Log-Eintrag erzeugt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger);
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
recordRepo.setLookupResult(new PersistenceLookupTechnicalFailure("Datenbank nicht erreichbar", null));
DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -618,7 +633,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass beim Überspringen eines bereits erfolgreich verarbeiteten Dokuments geloggt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger);
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -635,7 +651,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass beim Überspringen eines final fehlgeschlagenen Dokuments geloggt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger);
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
DocumentRecord existingRecord = buildRecord(ProcessingStatus.FAILED_FINAL, new FailureCounters(2, 0));
recordRepo.setLookupResult(new DocumentTerminalFinalFailure(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckFailed(
@@ -652,7 +669,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass nach erfolgreichem Persistieren einer neuen Datei geloggt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger);
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
recordRepo.setLookupResult(new DocumentUnknown());
DocumentProcessingOutcome outcome = new PreCheckPassed(
candidate, new PdfExtractionSuccess("text", new PdfPageCount(1)));
@@ -668,7 +686,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass bei Persistenzfehler ein Fehler-Log-Eintrag erzeugt wird
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger);
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
recordRepo.setLookupResult(new DocumentUnknown());
unitOfWorkPort.failOnExecute = true;
DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -685,7 +704,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass nach erfolgreichem Skip-Persistieren ein Debug-Log erzeugt wird (persistSkipAttempt L301)
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger);
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
DocumentProcessingOutcome outcome = new PreCheckPassed(
@@ -702,7 +722,8 @@ class DocumentProcessingCoordinatorTest {
// Prüft, dass bei Persistenzfehler im Skip-Pfad ein Fehler geloggt wird (persistSkipAttempt L306)
CapturingProcessingLogger capturingLogger = new CapturingProcessingLogger();
DocumentProcessingCoordinator coordinatorWithCapturingLogger =
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort, capturingLogger);
new DocumentProcessingCoordinator(recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), capturingLogger);
DocumentRecord existingRecord = buildRecord(ProcessingStatus.SUCCESS, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentTerminalSuccess(existingRecord));
unitOfWorkPort.failOnExecute = true;
@@ -715,10 +736,192 @@ class DocumentProcessingCoordinatorTest {
"Bei Persistenzfehler im Skip-Pfad muss ein Fehler geloggt werden");
}
// -------------------------------------------------------------------------
// PROPOSAL_READY finalization path
// -------------------------------------------------------------------------
@Test
void processDeferredOutcome_proposalReady_successfulCopy_persistsSuccessWithTargetFileName() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart,
c -> { throw new AssertionError("Pipeline must not run for PROPOSAL_READY"); });
assertTrue(result, "Finalization should succeed");
ProcessingAttempt successAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.SUCCESS)
.findFirst()
.orElse(null);
assertNotNull(successAttempt, "A SUCCESS attempt must be persisted");
assertNotNull(successAttempt.finalTargetFileName(), "SUCCESS attempt must carry the final target filename");
DocumentRecord updated = recordRepo.updatedRecords.get(0);
assertEquals(ProcessingStatus.SUCCESS, updated.overallStatus());
assertNotNull(updated.lastTargetFileName(), "Master record must carry the final target filename");
assertNotNull(updated.lastTargetPath(), "Master record must carry the target folder path");
assertNotNull(updated.lastSuccessInstant(), "lastSuccessInstant must be set on SUCCESS");
}
@Test
void processDeferredOutcome_proposalReady_missingProposalAttempt_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
// No PROPOSAL_READY attempt pre-populated
// persistTransientError returns true when the error record was persisted successfully
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted");
assertTrue(errorAttempt.retryable(), "Transient error must be retryable");
}
@Test
void processDeferredOutcome_proposalReady_inconsistentProposalNullDate_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
"model", "prompt", 1, 100, "{}", "reason",
null, DateSource.AI_PROVIDED, "Rechnung", null);
attemptRepo.savedAttempts.add(badProposal);
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted for inconsistent proposal state");
}
@Test
void processDeferredOutcome_proposalReady_duplicateResolutionFailure_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
DocumentProcessingCoordinator coordinatorWithFailingFolder = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new FailingTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger());
coordinatorWithFailingFolder.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted when duplicate resolution fails");
}
@Test
void processDeferredOutcome_proposalReady_copyFailure_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
DocumentProcessingCoordinator coordinatorWithFailingCopy = new DocumentProcessingCoordinator(
recordRepo, attemptRepo, unitOfWorkPort,
new NoOpTargetFolderPort(), new FailingTargetFileCopyPort(), new NoOpProcessingLogger());
coordinatorWithFailingCopy.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt, "A FAILED_RETRYABLE attempt must be persisted when file copy fails");
}
@Test
void processDeferredOutcome_proposalReady_inconsistentProposalTitleExceeds20Chars_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
// Title of 21 characters violates the 20-char base-title rule — inconsistent persistence state
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
"model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"A".repeat(21), null);
attemptRepo.savedAttempts.add(badProposal);
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt,
"A FAILED_RETRYABLE attempt must be persisted when the proposal title is inconsistent");
assertTrue(errorAttempt.retryable(), "Inconsistent proposal error must be retryable");
}
@Test
void processDeferredOutcome_proposalReady_inconsistentProposalTitleWithDisallowedChars_persistsTransientError() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
// Hyphen is a disallowed character in the fachliche Titelregel
ProcessingAttempt badProposal = new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
"model", "prompt", 1, 100, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED,
"Rechnung-2026", null);
attemptRepo.savedAttempts.add(badProposal);
processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
ProcessingAttempt errorAttempt = attemptRepo.savedAttempts.stream()
.filter(a -> a.status() == ProcessingStatus.FAILED_RETRYABLE)
.findFirst()
.orElse(null);
assertNotNull(errorAttempt,
"A FAILED_RETRYABLE attempt must be persisted when the proposal title has disallowed characters");
}
@Test
void processDeferredOutcome_proposalReady_persistenceFailureAfterCopy_returnsFalse() {
DocumentRecord existingRecord = buildRecord(ProcessingStatus.PROPOSAL_READY, FailureCounters.zero());
recordRepo.setLookupResult(new DocumentKnownProcessable(existingRecord));
attemptRepo.savedAttempts.add(buildValidProposalAttempt());
unitOfWorkPort.failOnExecute = true;
boolean result = processor.processDeferredOutcome(candidate, fingerprint, context, attemptStart, c -> null);
assertFalse(result, "Should return false when persistence fails after successful copy");
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private ProcessingAttempt buildValidProposalAttempt() {
return new ProcessingAttempt(
fingerprint, context.runId(), 1, Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY, null, null, false,
"gpt-4", "prompt-v1.txt", 1, 500, "{}", "reason",
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", null);
}
private DocumentProcessingOutcome buildNamingProposalOutcome() {
AiAttemptContext ctx = new AiAttemptContext(
"gpt-4", "prompt-v1.txt", 1, 500, "{\"title\":\"Rechnung\",\"reasoning\":\"r\"}");
NamingProposal proposal = new NamingProposal(
LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Rechnung", "AI reasoning");
return new NamingProposalReady(candidate, proposal, ctx);
}
private DocumentRecord buildRecord(ProcessingStatus status, FailureCounters counters) {
Instant now = Instant.now();
return new DocumentRecord(
@@ -730,7 +933,9 @@ class DocumentProcessingCoordinatorTest {
status == ProcessingStatus.SUCCESS ? null : now,
status == ProcessingStatus.SUCCESS ? now : null,
now,
now
now,
null,
null
);
}
@@ -785,8 +990,16 @@ class DocumentProcessingCoordinatorTest {
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
return List.copyOf(savedAttempts);
}
@Override
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
return savedAttempts.stream()
.filter(a -> a.status() == de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus.PROPOSAL_READY)
.reduce((first, second) -> second)
.orElse(null);
}
}
private static class CapturingUnitOfWorkPort implements UnitOfWorkPort {
private final CapturingDocumentRecordRepository recordRepo;
private final CapturingProcessingAttemptRepository attemptRepo;
@@ -850,6 +1063,58 @@ class DocumentProcessingCoordinatorTest {
}
}
private static class FailingTargetFolderPort implements TargetFolderPort {
@Override
public String getTargetFolderLocator() {
return "/tmp/target";
}
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
return new TargetFolderTechnicalFailure("Simulated folder resolution failure");
}
@Override
public void tryDeleteTargetFile(String resolvedFilename) {
// No-op
}
}
private static class FailingTargetFileCopyPort implements TargetFileCopyPort {
@Override
public TargetFileCopyResult copyToTarget(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
String resolvedFilename) {
return new TargetFileCopyTechnicalFailure("Simulated copy failure", false);
}
}
private static class NoOpTargetFolderPort implements TargetFolderPort {
@Override
public String getTargetFolderLocator() {
return "/tmp/target";
}
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
return new ResolvedTargetFilename(baseName);
}
@Override
public void tryDeleteTargetFile(String resolvedFilename) {
// No-op
}
}
private static class NoOpTargetFileCopyPort implements TargetFileCopyPort {
@Override
public TargetFileCopyResult copyToTarget(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
String resolvedFilename) {
return new TargetFileCopySuccess();
}
}
/** Zählt Logger-Aufrufe je Level, um VoidMethodCallMutator-Mutationen zu erkennen. */
private static class CapturingProcessingLogger implements ProcessingLogger {
int infoCallCount = 0;

View File

@@ -0,0 +1,94 @@
package de.gecheckt.pdf.umbenenner.application.service;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests for {@link DocumentTextLimiter}.
*/
class DocumentTextLimiterTest {
@Test
void limit_textShorterThanMax_returnsTextUnchanged() {
String text = "short text";
String result = DocumentTextLimiter.limit(text, 100);
assertThat(result).isEqualTo(text);
}
@Test
void limit_textExactlyMax_returnsTextUnchanged() {
String text = "exactly ten"; // 11 chars
String result = DocumentTextLimiter.limit(text, 11);
assertThat(result).isEqualTo(text);
assertThat(result).hasSize(11);
}
@Test
void limit_textLongerThanMax_returnsTruncatedText() {
String text = "Hello, World!";
String result = DocumentTextLimiter.limit(text, 5);
assertThat(result).isEqualTo("Hello");
assertThat(result).hasSize(5);
}
@Test
void limit_maxCharactersOne_returnsSingleChar() {
String text = "ABC";
String result = DocumentTextLimiter.limit(text, 1);
assertThat(result).isEqualTo("A");
}
@Test
void limit_emptyText_returnsEmptyString() {
String result = DocumentTextLimiter.limit("", 100);
assertThat(result).isEmpty();
}
@Test
void limit_emptyTextWithMinMax_returnsEmptyString() {
String result = DocumentTextLimiter.limit("", 1);
assertThat(result).isEmpty();
}
@Test
void limit_textWithUnicodeCharacters_respectsCharCount() {
// German umlauts are single chars in Java
String text = "Rechnungsübersicht"; // 18 chars
String result = DocumentTextLimiter.limit(text, 10);
assertThat(result).hasSize(10);
assertThat(result).startsWith("Rechnungs");
}
@Test
void limit_nullText_throwsNullPointerException() {
assertThatThrownBy(() -> DocumentTextLimiter.limit(null, 100))
.isInstanceOf(NullPointerException.class)
.hasMessage("text must not be null");
}
@Test
void limit_maxCharactersZero_throwsIllegalArgumentException() {
assertThatThrownBy(() -> DocumentTextLimiter.limit("text", 0))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("maxCharacters must be >= 1");
}
@Test
void limit_negativeMaxCharacters_throwsIllegalArgumentException() {
assertThatThrownBy(() -> DocumentTextLimiter.limit("text", -5))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("maxCharacters must be >= 1");
}
@Test
void limit_doesNotModifyOriginalText() {
String original = "This is the original document text that is long";
String limited = DocumentTextLimiter.limit(original, 10);
// The original String object is unchanged (Java Strings are immutable)
assertThat(limited).isNotSameAs(original);
assertThat(limited).hasSize(10);
}
}

View File

@@ -0,0 +1,264 @@
package de.gecheckt.pdf.umbenenner.application.service;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.service.TargetFilenameBuildingService.BaseFilenameReady;
import de.gecheckt.pdf.umbenenner.application.service.TargetFilenameBuildingService.BaseFilenameResult;
import de.gecheckt.pdf.umbenenner.application.service.TargetFilenameBuildingService.InconsistentProposalState;
import de.gecheckt.pdf.umbenenner.domain.model.DateSource;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
/**
* Unit tests for {@link TargetFilenameBuildingService}.
* <p>
* Covers the verbindliches Zielformat {@code YYYY-MM-DD - Titel.pdf}, the 20-character
* base-title rule, the fachliche Titelregel (only letters, digits, and spaces), and the
* detection of inconsistent persistence states.
*/
class TargetFilenameBuildingServiceTest {
private static final DocumentFingerprint FINGERPRINT =
new DocumentFingerprint("a".repeat(64));
private static final RunId RUN_ID = new RunId("run-test");
// -------------------------------------------------------------------------
// Null guard
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_rejectsNullAttempt() {
assertThatNullPointerException()
.isThrownBy(() -> TargetFilenameBuildingService.buildBaseFilename(null));
}
// -------------------------------------------------------------------------
// Happy path correct format
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_validProposal_returnsCorrectFormat() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 15), "Rechnung");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
assertThat(((BaseFilenameReady) result).baseFilename())
.isEqualTo("2026-01-15 - Rechnung.pdf");
}
@Test
void buildBaseFilename_dateWithLeadingZeros_formatsCorrectly() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 5), "Kontoauszug");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
assertThat(((BaseFilenameReady) result).baseFilename())
.isEqualTo("2026-03-05 - Kontoauszug.pdf");
}
@Test
void buildBaseFilename_titleWithDigits_isAccepted() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 6, 1), "Rechnung 2026");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
assertThat(((BaseFilenameReady) result).baseFilename())
.isEqualTo("2026-06-01 - Rechnung 2026.pdf");
}
@Test
void buildBaseFilename_titleWithGermanUmlauts_isAccepted() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 4, 7), "Strom Abr");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
}
@Test
void buildBaseFilename_titleWithUmlautsAndSzlig_isAccepted() {
// ä, ö, ü, ß are Unicode letters and must be accepted
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 4, 7), "Büroausgabe");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
assertThat(((BaseFilenameReady) result).baseFilename())
.isEqualTo("2026-04-07 - Büroausgabe.pdf");
}
@Test
void buildBaseFilename_titleExactly20Chars_isAccepted() {
String title = "A".repeat(20); // exactly 20 characters
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
}
// -------------------------------------------------------------------------
// 20-character rule applies only to base title; format structure is separate
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_format_separatorAndExtensionAreNotCountedAgainstTitle() {
// A 20-char title produces "YYYY-MM-DD - <20chars>.pdf" — total > 20 chars, which is fine
String title = "Stromabrechnung 2026"; // 20 chars
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 3, 31), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(BaseFilenameReady.class);
String filename = ((BaseFilenameReady) result).baseFilename();
assertThat(filename).isEqualTo("2026-03-31 - Stromabrechnung 2026.pdf");
// The service does not append duplicate suffixes; those are added by the target folder adapter
assertThat(filename).doesNotContain("(");
}
// -------------------------------------------------------------------------
// InconsistentProposalState null/invalid date
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_nullDate_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(null, "Rechnung");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("no resolved date");
}
// -------------------------------------------------------------------------
// InconsistentProposalState null/blank title
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_nullTitle_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), null);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("no validated title");
}
@Test
void buildBaseFilename_blankTitle_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), " ");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("no validated title");
}
// -------------------------------------------------------------------------
// InconsistentProposalState title exceeds 20 characters
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_titleExceeds20Chars_returnsInconsistentProposalState() {
String title = "A".repeat(21); // 21 characters
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), title);
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("exceeding 20 characters");
}
// -------------------------------------------------------------------------
// InconsistentProposalState disallowed characters in title
// -------------------------------------------------------------------------
@Test
void buildBaseFilename_titleWithHyphen_returnsInconsistentProposalState() {
// Hyphens are not letters, digits, or spaces — disallowed by fachliche Titelregel
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rechnung-2026");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
assertThat(((InconsistentProposalState) result).reason())
.contains("disallowed characters");
}
@Test
void buildBaseFilename_titleWithSlash_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rg/Strom");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
}
@Test
void buildBaseFilename_titleWithDot_returnsInconsistentProposalState() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 1, 1), "Rechnung.pdf");
BaseFilenameResult result = TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(result).isInstanceOf(InconsistentProposalState.class);
}
// -------------------------------------------------------------------------
// InconsistentProposalState reason field is non-null
// -------------------------------------------------------------------------
@Test
void incosistentProposalState_reason_isNeverNull() {
ProcessingAttempt attempt = proposalAttempt(null, "Rechnung");
InconsistentProposalState state =
(InconsistentProposalState) TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(state.reason()).isNotNull();
}
// -------------------------------------------------------------------------
// BaseFilenameReady result record is non-null and non-blank
// -------------------------------------------------------------------------
@Test
void baseFilenameReady_baseFilename_isNeverNullOrBlank() {
ProcessingAttempt attempt = proposalAttempt(LocalDate.of(2026, 7, 4), "Bescheid");
BaseFilenameReady ready =
(BaseFilenameReady) TargetFilenameBuildingService.buildBaseFilename(attempt);
assertThat(ready.baseFilename()).isNotNull().isNotBlank();
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private ProcessingAttempt proposalAttempt(LocalDate date, String title) {
return new ProcessingAttempt(
FINGERPRINT, RUN_ID, 1,
Instant.now(), Instant.now(),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"gpt-4", "prompt-v1.txt", 1, 100,
"{}", "reasoning text",
date, DateSource.AI_PROVIDED, title,
null);
}
}

View File

@@ -2,6 +2,9 @@ package de.gecheckt.pdf.umbenenner.application.usecase;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.application.port.out.ClockPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordLookupResult;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecordRepository;
@@ -14,12 +17,23 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PdfTextExtractionPort;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttemptRepository;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptLoadingSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.ResolvedTargetFilename;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyPort;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopyResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFileCopySuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFilenameResolutionResult;
import de.gecheckt.pdf.umbenenner.application.port.out.TargetFolderPort;
import de.gecheckt.pdf.umbenenner.application.port.out.PromptPort;
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.port.out.UnitOfWorkPort;
import de.gecheckt.pdf.umbenenner.application.service.AiNamingService;
import de.gecheckt.pdf.umbenenner.application.service.AiResponseValidator;
import de.gecheckt.pdf.umbenenner.application.service.DocumentProcessingCoordinator;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.PdfExtractionContentError;
@@ -445,7 +459,8 @@ class BatchRunProcessingUseCaseTest {
// Use a coordinator that always fails persistence
DocumentProcessingCoordinator failingProcessor = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) {
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
new NoOpProcessingLogger()) {
@Override
public boolean processDeferredOutcome(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate candidate,
@@ -488,7 +503,8 @@ class BatchRunProcessingUseCaseTest {
// Coordinator that succeeds for first document, fails persistence for second
DocumentProcessingCoordinator selectiveFailingProcessor = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) {
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
new NoOpProcessingLogger()) {
private int callCount = 0;
@Override
@@ -535,7 +551,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, new NoOpExtractionPort(),
alwaysFailingFingerprintPort, new NoOpDocumentProcessingCoordinator(),
capturingLogger);
buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("fp-warn"), Instant.now()));
@@ -556,7 +572,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), failingPort, new NoOpExtractionPort(),
new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
capturingLogger);
buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("source-err"), Instant.now()));
@@ -578,7 +594,8 @@ class BatchRunProcessingUseCaseTest {
// Coordinator der immer Persistenzfehler zurückgibt
DocumentProcessingCoordinator failingCoordinator = new DocumentProcessingCoordinator(
new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(),
new NoOpUnitOfWorkPort(), new NoOpProcessingLogger()) {
new NoOpUnitOfWorkPort(), new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(),
new NoOpProcessingLogger()) {
@Override
public boolean processDeferredOutcome(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate c,
@@ -592,7 +609,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, extractionPort,
new AlwaysSuccessFingerprintPort(), failingCoordinator, capturingLogger);
new AlwaysSuccessFingerprintPort(), failingCoordinator, buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("persist-warn"), Instant.now()));
@@ -610,7 +627,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), new EmptyCandidatesPort(), new NoOpExtractionPort(),
new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
capturingLogger);
buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("start-log"), Instant.now()));
@@ -630,7 +647,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, lockPort, new EmptyCandidatesPort(), new NoOpExtractionPort(),
new AlwaysSuccessFingerprintPort(), new NoOpDocumentProcessingCoordinator(),
capturingLogger);
buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("lock-warn"), Instant.now()));
@@ -659,11 +676,11 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, extractionPort,
new AlwaysSuccessFingerprintPort(), processor, capturingLogger);
new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("log-precheck"), Instant.now()));
// Ohne logExtractionResult wären es 4 debug()-Aufrufe; mit logExtractionResult 5
// Ohne logExtractionResult wären es mindestens 4 debug()-Aufrufe; mit logExtractionResult 5
assertTrue(capturingLogger.debugCallCount >= 5,
"logExtractionResult muss bei PdfExtractionSuccess debug() aufrufen (erwartet >= 5, war: "
+ capturingLogger.debugCallCount + ")");
@@ -689,7 +706,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, extractionPort,
new AlwaysSuccessFingerprintPort(), processor, capturingLogger);
new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("log-content-error"), Instant.now()));
@@ -718,7 +735,7 @@ class BatchRunProcessingUseCaseTest {
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
config, new MockRunLockPort(), candidatesPort, extractionPort,
new AlwaysSuccessFingerprintPort(), processor, capturingLogger);
new AlwaysSuccessFingerprintPort(), processor, buildStubAiNamingService(), capturingLogger);
useCase.execute(new BatchRunContext(new RunId("log-tech-error"), Instant.now()));
@@ -735,6 +752,20 @@ class BatchRunProcessingUseCaseTest {
// Helpers
// -------------------------------------------------------------------------
/**
* Builds a minimal stub {@link AiNamingService} that always returns an AI technical failure.
* Suitable for tests that do not care about the AI pipeline outcome.
*/
private static AiNamingService buildStubAiNamingService() {
AiInvocationPort stubAiPort = request ->
new AiInvocationTechnicalFailure(request, "STUBBED", "Stubbed AI for test");
PromptPort stubPromptPort = () ->
new PromptLoadingSuccess(new PromptIdentifier("stub-prompt"), "stub prompt content");
ClockPort stubClock = () -> java.time.Instant.EPOCH;
AiResponseValidator validator = new AiResponseValidator(stubClock);
return new AiNamingService(stubAiPort, stubPromptPort, validator, "stub-model", 1000);
}
private static DefaultBatchRunProcessingUseCase buildUseCase(
RuntimeConfiguration runtimeConfig,
RunLockPort lockPort,
@@ -744,7 +775,7 @@ class BatchRunProcessingUseCaseTest {
DocumentProcessingCoordinator processor) {
return new DefaultBatchRunProcessingUseCase(
runtimeConfig, lockPort, candidatesPort, extractionPort, fingerprintPort, processor,
new NoOpProcessingLogger());
buildStubAiNamingService(), new NoOpProcessingLogger());
}
private static RuntimeConfiguration buildConfig(Path tempDir) throws Exception {
@@ -906,7 +937,7 @@ class BatchRunProcessingUseCaseTest {
private static class NoOpDocumentProcessingCoordinator extends DocumentProcessingCoordinator {
NoOpDocumentProcessingCoordinator() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
new NoOpProcessingLogger());
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger());
}
}
@@ -918,7 +949,7 @@ class BatchRunProcessingUseCaseTest {
TrackingDocumentProcessingCoordinator() {
super(new NoOpDocumentRecordRepository(), new NoOpProcessingAttemptRepository(), new NoOpUnitOfWorkPort(),
new NoOpProcessingLogger());
new NoOpTargetFolderPort(), new NoOpTargetFileCopyPort(), new NoOpProcessingLogger());
}
@Override
@@ -948,6 +979,32 @@ class BatchRunProcessingUseCaseTest {
int processCallCount() { return processCallCount; }
}
private static class NoOpTargetFolderPort implements TargetFolderPort {
@Override
public String getTargetFolderLocator() {
return "/tmp/target";
}
@Override
public TargetFilenameResolutionResult resolveUniqueFilename(String baseName) {
return new ResolvedTargetFilename(baseName);
}
@Override
public void tryDeleteTargetFile(String resolvedFilename) {
// No-op
}
}
private static class NoOpTargetFileCopyPort implements TargetFileCopyPort {
@Override
public TargetFileCopyResult copyToTarget(
de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator sourceLocator,
String resolvedFilename) {
return new TargetFileCopySuccess();
}
}
/** No-op DocumentRecordRepository for use in test instances. */
private static class NoOpDocumentRecordRepository implements DocumentRecordRepository {
@Override
@@ -983,8 +1040,13 @@ class BatchRunProcessingUseCaseTest {
public List<ProcessingAttempt> findAllByFingerprint(DocumentFingerprint fingerprint) {
return List.of();
}
@Override
public ProcessingAttempt findLatestProposalReadyAttempt(DocumentFingerprint fingerprint) {
return null;
}
}
/** No-op UnitOfWorkPort for use in test instances. */
private static class NoOpUnitOfWorkPort implements UnitOfWorkPort {
@Override