M6 komplett umgesetzt
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user