1
0

V1.1 Änderungen

This commit is contained in:
2026-04-09 05:42:02 +02:00
parent 39800b6ea8
commit 5099ff4aca
44 changed files with 4912 additions and 957 deletions

View File

@@ -0,0 +1,394 @@
package de.gecheckt.pdf.umbenenner.adapter.out.ai;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Objects;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
/**
* Adapter implementing the native Anthropic Messages API for AI service invocation.
* <p>
* This adapter:
* <ul>
* <li>Translates an abstract {@link AiRequestRepresentation} into an Anthropic
* Messages API request (POST {@code /v1/messages})</li>
* <li>Configures HTTP connection, timeout, and authentication from the provider
* configuration using the Anthropic-specific authentication scheme
* ({@code x-api-key} header, not {@code Authorization: Bearer})</li>
* <li>Extracts the response text by concatenating all {@code text}-type content
* blocks from the Anthropic response, returning the result as a raw response
* for Application-layer parsing and validation</li>
* <li>Classifies technical failures (HTTP errors, timeouts, missing content blocks,
* unparseable JSON) according to the existing transient error semantics</li>
* </ul>
*
* <h2>Configuration</h2>
* <ul>
* <li>{@code baseUrl} — the HTTP(S) base URL; defaults to {@code https://api.anthropic.com}
* when absent or blank</li>
* <li>{@code model} — the Claude model identifier (e.g., {@code claude-3-5-sonnet-20241022})</li>
* <li>{@code timeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token, resolved from environment variable
* {@code ANTHROPIC_API_KEY} or property {@code ai.provider.claude.apiKey};
* environment variable takes precedence (resolved by the configuration layer
* before this adapter is constructed)</li>
* </ul>
*
* <h2>HTTP request structure</h2>
* <p>
* The adapter sends a POST request to {@code {baseUrl}/v1/messages} with:
* <ul>
* <li>Header {@code x-api-key} containing the resolved API key</li>
* <li>Header {@code anthropic-version: 2023-06-01}</li>
* <li>Header {@code content-type: application/json}</li>
* <li>JSON body containing:
* <ul>
* <li>{@code model} — the configured model name</li>
* <li>{@code max_tokens} — fixed at 1024; sufficient for the expected JSON response
* without requiring a separate configuration property</li>
* <li>{@code system} — the prompt content (if non-blank); Anthropic uses a
* top-level field instead of a {@code role=system} message</li>
* <li>{@code messages} — an array with exactly one {@code user} message containing
* the document text</li>
* </ul>
* </li>
* </ul>
*
* <h2>Response handling</h2>
* <ul>
* <li><strong>HTTP 200:</strong> All {@code content} blocks with {@code type=="text"}
* are concatenated in order; the result is returned as {@link AiInvocationSuccess}
* with an {@link AiRawResponse} containing the concatenated text. The Application
* layer then parses and validates this text as a NamingProposal JSON object.</li>
* <li><strong>No text blocks in HTTP 200 response:</strong> Classified as a technical
* failure; the Application layer cannot derive a naming proposal without text.</li>
* <li><strong>Unparseable response JSON:</strong> Classified as a technical failure.</li>
* <li><strong>HTTP non-200:</strong> Classified as a technical failure.</li>
* </ul>
*
* <h2>Technical error classification</h2>
* <p>
* All errors are mapped to {@link AiInvocationTechnicalFailure} and follow the existing
* transient error semantics. No new error categories are introduced:
* <ul>
* <li>HTTP 4xx (including 401, 403, 429) and 5xx — technical failure</li>
* <li>Connection timeout, read timeout — {@code TIMEOUT}</li>
* <li>Connection failure — {@code CONNECTION_ERROR}</li>
* <li>DNS failure — {@code DNS_ERROR}</li>
* <li>IO errors — {@code IO_ERROR}</li>
* <li>Interrupted operation — {@code INTERRUPTED}</li>
* <li>JSON not parseable — {@code UNPARSEABLE_JSON}</li>
* <li>No {@code text}-type content block in response — {@code NO_TEXT_CONTENT}</li>
* </ul>
*
* <h2>Non-goals</h2>
* <ul>
* <li>NamingProposal JSON parsing or validation — the Application layer owns this</li>
* <li>Retry logic — this adapter executes a single request only</li>
* <li>Shared implementation with the OpenAI-compatible adapter — no common base class</li>
* </ul>
*/
public class AnthropicClaudeHttpAdapter implements AiInvocationPort {
private static final Logger LOG = LogManager.getLogger(AnthropicClaudeHttpAdapter.class);
private static final String MESSAGES_ENDPOINT = "/v1/messages";
private static final String ANTHROPIC_VERSION_HEADER = "anthropic-version";
private static final String ANTHROPIC_VERSION_VALUE = "2023-06-01";
private static final String API_KEY_HEADER = "x-api-key";
private static final String CONTENT_TYPE = "application/json";
private static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
/**
* Fixed max_tokens value for the Anthropic request.
* <p>
* This value is sufficient for the expected NamingProposal JSON response
* ({@code date}, {@code title}, {@code reasoning}) without requiring a separate
* configuration property. Anthropic's API requires this field to be present.
*/
private static final int MAX_TOKENS = 1024;
private final HttpClient httpClient;
private final URI apiBaseUrl;
private final String apiModel;
private final String apiKey;
private final int apiTimeoutSeconds;
// Test-only field to capture the last built JSON body for assertion
private volatile String lastBuiltJsonBody;
/**
* Creates an adapter from the Claude provider configuration.
* <p>
* If {@code config.baseUrl()} is absent or blank, the default Anthropic endpoint
* {@code https://api.anthropic.com} is used. The HTTP client is initialized with
* the configured timeout.
*
* @param config the provider configuration for the Claude family; must not be null
* @throws NullPointerException if config is null
* @throws IllegalArgumentException if the model is missing or blank
*/
public AnthropicClaudeHttpAdapter(ProviderConfiguration config) {
this(config, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(config.timeoutSeconds()))
.build());
}
/**
* Creates an adapter with a custom HTTP client (primarily for testing).
* <p>
* This constructor allows tests to inject a mock or configurable HTTP client
* while keeping configuration validation consistent with the production constructor.
* <p>
* <strong>For testing only:</strong> This is package-private to remain internal to the adapter.
*
* @param config the provider configuration; must not be null
* @param httpClient the HTTP client to use; must not be null
* @throws NullPointerException if config or httpClient is null
* @throws IllegalArgumentException if the model is missing or blank
*/
AnthropicClaudeHttpAdapter(ProviderConfiguration config, HttpClient httpClient) {
Objects.requireNonNull(config, "config must not be null");
Objects.requireNonNull(httpClient, "httpClient must not be null");
if (config.model() == null || config.model().isBlank()) {
throw new IllegalArgumentException("API model must not be null or empty");
}
String baseUrlStr = (config.baseUrl() != null && !config.baseUrl().isBlank())
? config.baseUrl()
: DEFAULT_BASE_URL;
this.apiBaseUrl = URI.create(baseUrlStr);
this.apiModel = config.model();
this.apiKey = config.apiKey() != null ? config.apiKey() : "";
this.apiTimeoutSeconds = config.timeoutSeconds();
this.httpClient = httpClient;
LOG.debug("AnthropicClaudeHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s",
apiBaseUrl, apiModel, apiTimeoutSeconds);
}
/**
* Invokes the Anthropic Claude AI service with the given request.
* <p>
* Constructs an Anthropic Messages API request from the request representation,
* executes it, extracts the text content from the response, and returns either
* a successful response or a classified technical failure.
*
* @param request the AI request with prompt and document text; must not be null
* @return an {@link AiInvocationResult} encoding either success (with extracted text)
* or a technical failure with classified reason
* @throws NullPointerException if request is null
*/
@Override
public AiInvocationResult invoke(AiRequestRepresentation request) {
Objects.requireNonNull(request, "request must not be null");
try {
HttpRequest httpRequest = buildRequest(request);
HttpResponse<String> response = executeRequest(httpRequest);
if (response.statusCode() == 200) {
return extractTextFromResponse(request, response.body());
} else {
String reason = "HTTP_" + response.statusCode();
String message = "Anthropic AI service returned status " + response.statusCode();
LOG.warn("Claude AI invocation returned non-200 status: {}", response.statusCode());
return new AiInvocationTechnicalFailure(request, reason, message);
}
} catch (java.net.http.HttpTimeoutException e) {
String message = "HTTP timeout: " + e.getClass().getSimpleName();
LOG.warn("Claude AI invocation timeout: {}", message);
return new AiInvocationTechnicalFailure(request, "TIMEOUT", message);
} catch (java.net.ConnectException e) {
String message = "Failed to connect to endpoint: " + e.getMessage();
LOG.warn("Claude AI invocation connection error: {}", message);
return new AiInvocationTechnicalFailure(request, "CONNECTION_ERROR", message);
} catch (java.net.UnknownHostException e) {
String message = "Endpoint hostname not resolvable: " + e.getMessage();
LOG.warn("Claude AI invocation DNS error: {}", message);
return new AiInvocationTechnicalFailure(request, "DNS_ERROR", message);
} catch (java.io.IOException e) {
String message = "IO error during AI invocation: " + e.getMessage();
LOG.warn("Claude AI invocation IO error: {}", message);
return new AiInvocationTechnicalFailure(request, "IO_ERROR", message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
String message = "AI invocation interrupted: " + e.getMessage();
LOG.warn("Claude AI invocation interrupted: {}", message);
return new AiInvocationTechnicalFailure(request, "INTERRUPTED", message);
} catch (Exception e) {
String message = "Unexpected error during AI invocation: " + e.getClass().getSimpleName()
+ " - " + e.getMessage();
LOG.error("Unexpected error in Claude AI invocation", e);
return new AiInvocationTechnicalFailure(request, "UNEXPECTED_ERROR", message);
}
}
/**
* Builds an Anthropic Messages API request from the request representation.
* <p>
* Constructs:
* <ul>
* <li>Endpoint URL: {@code {apiBaseUrl}/v1/messages}</li>
* <li>Headers: {@code x-api-key}, {@code anthropic-version: 2023-06-01},
* {@code content-type: application/json}</li>
* <li>Body: JSON with {@code model}, {@code max_tokens}, optional {@code system}
* (prompt content), and {@code messages} with a single user message
* (document text)</li>
* <li>Timeout: configured timeout from provider configuration</li>
* </ul>
*
* @param request the request representation with prompt and document text
* @return an {@link HttpRequest} ready to send
*/
private HttpRequest buildRequest(AiRequestRepresentation request) {
URI endpoint = buildEndpointUri();
String requestBody = buildJsonRequestBody(request);
// Capture for test inspection (test-only field)
this.lastBuiltJsonBody = requestBody;
return HttpRequest.newBuilder(endpoint)
.header("content-type", CONTENT_TYPE)
.header(API_KEY_HEADER, apiKey)
.header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.timeout(Duration.ofSeconds(apiTimeoutSeconds))
.build();
}
/**
* Composes the endpoint URI from the configured base URL.
* <p>
* Resolves {@code {apiBaseUrl}/v1/messages}.
*
* @return the complete endpoint URI
*/
private URI buildEndpointUri() {
String endpointPath = apiBaseUrl.getPath().replaceAll("/$", "") + MESSAGES_ENDPOINT;
return URI.create(apiBaseUrl.getScheme() + "://" +
apiBaseUrl.getHost() +
(apiBaseUrl.getPort() > 0 ? ":" + apiBaseUrl.getPort() : "") +
endpointPath);
}
/**
* Builds the JSON request body for the Anthropic Messages API.
* <p>
* The body contains:
* <ul>
* <li>{@code model} — the configured model name</li>
* <li>{@code max_tokens} — fixed value sufficient for the expected response</li>
* <li>{@code system} — the prompt content as a top-level field (only when non-blank;
* Anthropic does not accept {@code role=system} inside the {@code messages} array)</li>
* <li>{@code messages} — an array with exactly one user message containing the
* document text</li>
* </ul>
* <p>
* <strong>Package-private for testing:</strong> This method is accessible to tests
* in the same package to verify the actual JSON body structure and content.
*
* @param request the request with prompt and document text
* @return JSON string ready to send in HTTP body
*/
String buildJsonRequestBody(AiRequestRepresentation request) {
JSONObject body = new JSONObject();
body.put("model", apiModel);
body.put("max_tokens", MAX_TOKENS);
// Prompt content goes to the top-level system field (not a role=system message)
if (request.promptContent() != null && !request.promptContent().isBlank()) {
body.put("system", request.promptContent());
}
JSONObject userMessage = new JSONObject();
userMessage.put("role", "user");
userMessage.put("content", request.documentText());
body.put("messages", new JSONArray().put(userMessage));
return body.toString();
}
/**
* Extracts the text content from a successful (HTTP 200) Anthropic response.
* <p>
* Concatenates all {@code content} blocks with {@code type=="text"} in order.
* Blocks of other types (e.g., tool use) are ignored.
* If no {@code text} blocks are present, a technical failure is returned.
*
* @param request the original request (carried through to the result)
* @param responseBody the raw HTTP response body
* @return success with the concatenated text, or a technical failure
*/
private AiInvocationResult extractTextFromResponse(AiRequestRepresentation request, String responseBody) {
try {
JSONObject json = new JSONObject(responseBody);
JSONArray contentArray = json.getJSONArray("content");
StringBuilder textBuilder = new StringBuilder();
for (int i = 0; i < contentArray.length(); i++) {
JSONObject block = contentArray.getJSONObject(i);
if ("text".equals(block.optString("type"))) {
textBuilder.append(block.getString("text"));
}
}
String extractedText = textBuilder.toString();
if (extractedText.isEmpty()) {
LOG.warn("Claude AI response contained no text-type content blocks");
return new AiInvocationTechnicalFailure(request, "NO_TEXT_CONTENT",
"Anthropic response contained no text-type content blocks");
}
return new AiInvocationSuccess(request, new AiRawResponse(extractedText));
} catch (JSONException e) {
LOG.warn("Claude AI response could not be parsed as JSON: {}", e.getMessage());
return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
"Anthropic response body is not valid JSON: " + e.getMessage());
}
}
/**
* Package-private accessor for the last constructed JSON body.
* <p>
* <strong>For testing only:</strong> Allows tests to verify the actual
* JSON body sent in HTTP requests without exposing the BodyPublisher internals.
*
* @return the last JSON body string constructed by {@link #buildRequest(AiRequestRepresentation)},
* or null if no request has been built yet
*/
String getLastBuiltJsonBodyForTesting() {
return lastBuiltJsonBody;
}
/**
* Executes the HTTP request and returns the response.
*
* @param httpRequest the HTTP request to execute
* @return the HTTP response with status code and body
* @throws java.net.http.HttpTimeoutException if the request times out
* @throws java.net.ConnectException if connection fails
* @throws java.io.IOException on other IO errors
* @throws InterruptedException if the request is interrupted
*/
private HttpResponse<String> executeRequest(HttpRequest httpRequest)
throws java.io.IOException, InterruptedException {
return httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
}
}

View File

@@ -11,7 +11,7 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
@@ -26,7 +26,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
* <ul>
* <li>Translates an abstract {@link AiRequestRepresentation} into an OpenAI Chat
* Completions API request</li>
* <li>Configures HTTP connection, timeout, and authentication from the startup configuration</li>
* <li>Configures HTTP connection, timeout, and authentication from the provider configuration</li>
* <li>Executes the HTTP request against the configured AI endpoint</li>
* <li>Distinguishes between successful HTTP responses (200) and technical failures
* (timeout, unreachable, connection error, etc.)</li>
@@ -36,16 +36,16 @@ import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
* <p>
* <strong>Configuration:</strong>
* <ul>
* <li>{@code apiBaseUrl} — the HTTP(S) base URL of the AI service endpoint</li>
* <li>{@code apiModel} — the model identifier requested from the AI service</li>
* <li>{@code apiTimeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token (already resolved from environment
* variable {@code PDF_UMBENENNER_API_KEY} or property {@code api.key},
* <li>{@code baseUrl} — the HTTP(S) base URL of the AI service endpoint</li>
* <li>{@code model} — the model identifier requested from the AI service</li>
* <li>{@code timeoutSeconds} — connection and read timeout in seconds</li>
* <li>{@code apiKey} — the authentication token (resolved from environment variable
* {@code OPENAI_COMPATIBLE_API_KEY} or property {@code ai.provider.openai-compatible.apiKey},
* environment variable takes precedence)</li>
* </ul>
* <p>
* <strong>HTTP request structure:</strong>
* The adapter sends a POST request to the endpoint {@code {apiBaseUrl}/v1/chat/completions}
* The adapter sends a POST request to the endpoint {@code {baseUrl}/v1/chat/completions}
* with:
* <ul>
* <li>Authorization header containing the API key</li>
@@ -106,19 +106,18 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
private volatile String lastBuiltJsonBody;
/**
* Creates an adapter with configuration from startup configuration.
* Creates an adapter from the OpenAI-compatible provider configuration.
* <p>
* The adapter initializes an HTTP client with the configured timeout and creates
* the endpoint URL from the base URL. Configuration values are validated for
* null/empty during initialization.
* The adapter initializes an HTTP client with the configured timeout and parses
* the endpoint URI from the configured base URL string.
*
* @param config the startup configuration containing API settings; must not be null
* @param config the provider configuration for the OpenAI-compatible family; must not be null
* @throws NullPointerException if config is null
* @throws IllegalArgumentException if API base URL or model is missing/empty
* @throws IllegalArgumentException if the base URL or model is missing/blank
*/
public OpenAiHttpAdapter(StartConfiguration config) {
public OpenAiHttpAdapter(ProviderConfiguration config) {
this(config, HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(config.apiTimeoutSeconds()))
.connectTimeout(Duration.ofSeconds(config.timeoutSeconds()))
.build());
}
@@ -130,25 +129,25 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
* <p>
* <strong>For testing only:</strong> This is package-private to remain internal to the adapter.
*
* @param config the startup configuration containing API settings; must not be null
* @param config the provider configuration; must not be null
* @param httpClient the HTTP client to use; must not be null
* @throws NullPointerException if config or httpClient is null
* @throws IllegalArgumentException if API base URL or model is missing/empty
* @throws IllegalArgumentException if the base URL or model is missing/blank
*/
OpenAiHttpAdapter(StartConfiguration config, HttpClient httpClient) {
OpenAiHttpAdapter(ProviderConfiguration config, HttpClient httpClient) {
Objects.requireNonNull(config, "config must not be null");
Objects.requireNonNull(httpClient, "httpClient must not be null");
if (config.apiBaseUrl() == null) {
if (config.baseUrl() == null || config.baseUrl().isBlank()) {
throw new IllegalArgumentException("API base URL must not be null");
}
if (config.apiModel() == null || config.apiModel().isBlank()) {
if (config.model() == null || config.model().isBlank()) {
throw new IllegalArgumentException("API model must not be null or empty");
}
this.apiBaseUrl = config.apiBaseUrl();
this.apiModel = config.apiModel();
this.apiBaseUrl = URI.create(config.baseUrl());
this.apiModel = config.model();
this.apiKey = config.apiKey() != null ? config.apiKey() : "";
this.apiTimeoutSeconds = config.apiTimeoutSeconds();
this.apiTimeoutSeconds = config.timeoutSeconds();
this.httpClient = httpClient;
LOG.debug("OpenAiHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s",
@@ -229,7 +228,7 @@ public class OpenAiHttpAdapter implements AiInvocationPort {
* <li>Endpoint URL: {@code {apiBaseUrl}/v1/chat/completions}</li>
* <li>Headers: Authorization with Bearer token, Content-Type application/json</li>
* <li>Body: JSON with model, messages (system = prompt, user = document text)</li>
* <li>Timeout: configured timeout from startup configuration</li>
* <li>Timeout: configured timeout from provider configuration</li>
* </ul>
*
* @param request the request representation with prompt and document text

View File

@@ -10,6 +10,7 @@ import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* Validates {@link StartConfiguration} before processing can begin.
* <p>
@@ -156,13 +157,13 @@ public class StartConfigurationValidator {
validateSourceFolder(config.sourceFolder(), errors);
validateTargetFolder(config.targetFolder(), errors);
validateSqliteFile(config.sqliteFile(), errors);
validateApiBaseUrl(config.apiBaseUrl(), errors);
validateApiModel(config.apiModel(), errors);
validatePromptTemplateFile(config.promptTemplateFile(), errors);
if (config.multiProviderConfiguration() == null) {
errors.add("- ai provider configuration: must not be null");
}
}
private void validateNumericConstraints(StartConfiguration config, List<String> errors) {
validateApiTimeoutSeconds(config.apiTimeoutSeconds(), errors);
validateMaxRetriesTransient(config.maxRetriesTransient(), errors);
validateMaxPages(config.maxPages(), errors);
validateMaxTextCharacters(config.maxTextCharacters(), errors);
@@ -199,33 +200,6 @@ public class StartConfigurationValidator {
validateRequiredFileParentDirectory(sqliteFile, "sqlite.file", errors);
}
private void validateApiBaseUrl(java.net.URI apiBaseUrl, List<String> errors) {
if (apiBaseUrl == null) {
errors.add("- api.baseUrl: must not be null");
return;
}
if (!apiBaseUrl.isAbsolute()) {
errors.add("- api.baseUrl: must be an absolute URI: " + apiBaseUrl);
return;
}
String scheme = apiBaseUrl.getScheme();
if (scheme == null || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) {
errors.add("- api.baseUrl: scheme must be http or https, got: " + scheme);
}
}
private void validateApiModel(String apiModel, List<String> errors) {
if (apiModel == null || apiModel.isBlank()) {
errors.add("- api.model: must not be null or blank");
}
}
private void validateApiTimeoutSeconds(int apiTimeoutSeconds, List<String> errors) {
if (apiTimeoutSeconds <= 0) {
errors.add("- api.timeoutSeconds: must be > 0, got: " + apiTimeoutSeconds);
}
}
private void validateMaxRetriesTransient(int maxRetriesTransient, List<String> errors) {
if (maxRetriesTransient < 1) {
errors.add("- max.retries.transient: must be >= 1, got: " + maxRetriesTransient);

View File

@@ -0,0 +1,306 @@
package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Properties;
/**
* Detects and migrates a legacy flat-key configuration file to the multi-provider schema.
*
* <h2>Legacy form</h2>
* A configuration file is considered legacy if it contains at least one of the flat property
* keys ({@code api.baseUrl}, {@code api.model}, {@code api.timeoutSeconds}, {@code api.key})
* and does <em>not</em> already contain {@code ai.provider.active}.
*
* <h2>Migration procedure</h2>
* <ol>
* <li>Detect legacy form; if absent, return immediately without any I/O side effect.</li>
* <li>Create a {@code .bak} backup of the original file before any changes. If a {@code .bak}
* file already exists, a numbered suffix is appended ({@code .bak.1}, {@code .bak.2}, …).
* Existing backups are never overwritten.</li>
* <li>Rewrite the file:
* <ul>
* <li>{@code api.baseUrl} → {@code ai.provider.openai-compatible.baseUrl}</li>
* <li>{@code api.model} → {@code ai.provider.openai-compatible.model}</li>
* <li>{@code api.timeoutSeconds} → {@code ai.provider.openai-compatible.timeoutSeconds}</li>
* <li>{@code api.key} → {@code ai.provider.openai-compatible.apiKey}</li>
* <li>{@code ai.provider.active=openai-compatible} is appended.</li>
* <li>A commented placeholder section for the Claude provider is appended.</li>
* <li>All other keys are carried over unchanged in stable order.</li>
* </ul>
* </li>
* <li>Write the migrated content via a temporary file ({@code <file>.tmp}) followed by an
* atomic move/rename. The original file is never partially overwritten.</li>
* <li>Reload the migrated file and validate it with {@link MultiProviderConfigurationParser}
* and {@link MultiProviderConfigurationValidator}. If validation fails, a
* {@link ConfigurationLoadingException} is thrown; the {@code .bak} is preserved.</li>
* </ol>
*/
public class LegacyConfigurationMigrator {
private static final Logger LOG = LogManager.getLogger(LegacyConfigurationMigrator.class);
/** Legacy flat key for base URL, replaced during migration. */
static final String LEGACY_BASE_URL = "api.baseUrl";
/** Legacy flat key for model name, replaced during migration. */
static final String LEGACY_MODEL = "api.model";
/** Legacy flat key for timeout, replaced during migration. */
static final String LEGACY_TIMEOUT = "api.timeoutSeconds";
/** Legacy flat key for API key, replaced during migration. */
static final String LEGACY_API_KEY = "api.key";
private static final String[][] LEGACY_KEY_MAPPINGS = {
{LEGACY_BASE_URL, "ai.provider.openai-compatible.baseUrl"},
{LEGACY_MODEL, "ai.provider.openai-compatible.model"},
{LEGACY_TIMEOUT, "ai.provider.openai-compatible.timeoutSeconds"},
{LEGACY_API_KEY, "ai.provider.openai-compatible.apiKey"},
};
private final MultiProviderConfigurationParser parser;
private final MultiProviderConfigurationValidator validator;
/**
* Creates a migrator backed by default parser and validator instances.
*/
public LegacyConfigurationMigrator() {
this(new MultiProviderConfigurationParser(), new MultiProviderConfigurationValidator());
}
/**
* Creates a migrator with injected parser and validator.
* <p>
* Intended for testing, where a controlled (e.g. always-failing) validator can be supplied
* to verify that the {@code .bak} backup is preserved when post-migration validation fails.
*
* @param parser parser used to re-read the migrated file; must not be {@code null}
* @param validator validator used to verify the migrated file; must not be {@code null}
*/
public LegacyConfigurationMigrator(MultiProviderConfigurationParser parser,
MultiProviderConfigurationValidator validator) {
this.parser = parser;
this.validator = validator;
}
/**
* Migrates the configuration file at {@code configFilePath} if it is in legacy form.
* <p>
* If the file does not contain legacy flat keys or already contains
* {@code ai.provider.active}, this method returns immediately without any I/O side effect.
*
* @param configFilePath path to the configuration file; must exist and be readable
* @throws ConfigurationLoadingException if the file cannot be read, the backup cannot be
* created, the migrated file cannot be written, or post-migration validation fails
*/
public void migrateIfLegacy(Path configFilePath) {
String originalContent = readFile(configFilePath);
Properties props = parsePropertiesFromContent(originalContent);
if (!isLegacyForm(props)) {
return;
}
LOG.info("Legacy configuration format detected. Migrating: {}", configFilePath);
createBakBackup(configFilePath, originalContent);
String migratedContent = generateMigratedContent(originalContent);
writeAtomically(configFilePath, migratedContent);
LOG.info("Configuration file migrated to multi-provider schema: {}", configFilePath);
validateMigratedFile(configFilePath);
}
/**
* Returns {@code true} if the given properties are in legacy form.
* <p>
* A properties set is considered legacy when it contains at least one of the four
* flat legacy keys and does not already contain {@code ai.provider.active}.
*
* @param props the parsed properties to inspect; must not be {@code null}
* @return {@code true} if migration is required, {@code false} otherwise
*/
boolean isLegacyForm(Properties props) {
boolean hasLegacyKey = props.containsKey(LEGACY_BASE_URL)
|| props.containsKey(LEGACY_MODEL)
|| props.containsKey(LEGACY_TIMEOUT)
|| props.containsKey(LEGACY_API_KEY);
boolean hasNewKey = props.containsKey(MultiProviderConfigurationParser.PROP_ACTIVE_PROVIDER);
return hasLegacyKey && !hasNewKey;
}
/**
* Creates a backup of the original file before overwriting it.
* <p>
* If {@code <file>.bak} does not yet exist, it is written directly. Otherwise,
* numbered suffixes ({@code .bak.1}, {@code .bak.2}, …) are tried in ascending order
* until a free slot is found. Existing backups are never overwritten.
*/
private void createBakBackup(Path configFilePath, String content) {
Path bakPath = configFilePath.resolveSibling(configFilePath.getFileName() + ".bak");
if (!Files.exists(bakPath)) {
writeFile(bakPath, content);
LOG.info("Backup created: {}", bakPath);
return;
}
for (int i = 1; ; i++) {
Path numbered = configFilePath.resolveSibling(configFilePath.getFileName() + ".bak." + i);
if (!Files.exists(numbered)) {
writeFile(numbered, content);
LOG.info("Backup created: {}", numbered);
return;
}
}
}
/**
* Produces the migrated file content from the given original content string.
* <p>
* Each line is inspected: lines that define a legacy key are rewritten with the
* corresponding new namespaced key; all other lines (comments, blank lines, other keys)
* pass through unchanged. After all original lines, a {@code ai.provider.active} entry
* and a commented Claude-provider placeholder block are appended.
*
* @param originalContent the raw original file content; must not be {@code null}
* @return the migrated content ready to be written to disk
*/
String generateMigratedContent(String originalContent) {
String[] lines = originalContent.split("\\r?\\n", -1);
StringBuilder sb = new StringBuilder();
for (String line : lines) {
sb.append(transformLine(line)).append("\n");
}
sb.append("\n");
sb.append("# Aktiver KI-Provider: openai-compatible oder claude\n");
sb.append("ai.provider.active=openai-compatible\n");
sb.append("\n");
sb.append("# Anthropic Claude-Provider (nur benoetigt wenn ai.provider.active=claude)\n");
sb.append("# ai.provider.claude.model=\n");
sb.append("# ai.provider.claude.timeoutSeconds=\n");
sb.append("# ai.provider.claude.apiKey=\n");
return sb.toString();
}
/**
* Transforms a single properties-file line, replacing a legacy key with its new equivalent.
* <p>
* Comment lines, blank lines, and lines defining keys other than the four legacy keys
* are returned unchanged.
*/
private String transformLine(String line) {
for (String[] mapping : LEGACY_KEY_MAPPINGS) {
String legacyKey = mapping[0];
String newKey = mapping[1];
if (lineDefinesKey(line, legacyKey)) {
int keyStart = line.indexOf(legacyKey);
return line.substring(0, keyStart) + newKey + line.substring(keyStart + legacyKey.length());
}
}
return line;
}
/**
* Returns {@code true} when {@code line} defines the given {@code key}.
* <p>
* A line defines a key if — after stripping any leading whitespace — it starts with
* the exact key string followed by {@code =}, {@code :}, whitespace, or end-of-string.
* Comment-introducing characters ({@code #} or {@code !}) cause an immediate {@code false}.
*/
private boolean lineDefinesKey(String line, String key) {
String trimmed = line.stripLeading();
if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("!")) {
return false;
}
if (!trimmed.startsWith(key)) {
return false;
}
if (trimmed.length() == key.length()) {
return true;
}
char next = trimmed.charAt(key.length());
return next == '=' || next == ':' || Character.isWhitespace(next);
}
/**
* Writes {@code content} to {@code target} via a temporary file and an atomic rename.
* <p>
* The temporary file is created as {@code <target>.tmp} in the same directory.
* After the content is fully written, the temporary file is moved to {@code target},
* replacing it. The original file is therefore never partially overwritten.
*/
private void writeAtomically(Path target, String content) {
Path tmpPath = target.resolveSibling(target.getFileName() + ".tmp");
try {
Files.writeString(tmpPath, content, StandardCharsets.UTF_8);
Files.move(tmpPath, target, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
throw new ConfigurationLoadingException(
"Failed to write migrated configuration to " + target, e);
}
}
/**
* Re-reads the migrated file and validates it using the injected parser and validator.
* <p>
* A parse or validation failure is treated as a hard startup error. The {@code .bak} backup
* created before migration is preserved in this case.
*/
private void validateMigratedFile(Path configFilePath) {
String content = readFile(configFilePath);
Properties props = parsePropertiesFromContent(content);
MultiProviderConfiguration config;
try {
config = parser.parse(props);
} catch (ConfigurationLoadingException e) {
throw new ConfigurationLoadingException(
"Migrated configuration failed to parse: " + e.getMessage(), e);
}
try {
validator.validate(config);
} catch (InvalidStartConfigurationException e) {
throw new ConfigurationLoadingException(
"Migrated configuration failed validation (backup preserved): " + e.getMessage(), e);
}
}
private String readFile(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new ConfigurationLoadingException("Failed to read file: " + path, e);
}
}
private void writeFile(Path path, String content) {
try {
Files.writeString(path, content, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new ConfigurationLoadingException("Failed to write file: " + path, e);
}
}
private Properties parsePropertiesFromContent(String content) {
Properties props = new Properties();
try {
props.load(new StringReader(content));
} catch (IOException e) {
throw new ConfigurationLoadingException("Failed to parse properties content", e);
}
return props;
}
}

View File

@@ -0,0 +1,203 @@
package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import java.util.Properties;
import java.util.function.Function;
/**
* Parses the multi-provider configuration schema from a {@link Properties} object.
* <p>
* Recognises the following property keys:
* <pre>
* ai.provider.active required; must be "openai-compatible" or "claude"
* ai.provider.openai-compatible.baseUrl required for active OpenAI-compatible provider
* ai.provider.openai-compatible.model required for active OpenAI-compatible provider
* ai.provider.openai-compatible.timeoutSeconds
* ai.provider.openai-compatible.apiKey
* ai.provider.claude.baseUrl optional; defaults to https://api.anthropic.com
* ai.provider.claude.model required for active Claude provider
* ai.provider.claude.timeoutSeconds
* ai.provider.claude.apiKey
* </pre>
*
* <h2>Environment-variable precedence for API keys</h2>
* <ul>
* <li>{@code OPENAI_COMPATIBLE_API_KEY} overrides {@code ai.provider.openai-compatible.apiKey}</li>
* <li>{@code ANTHROPIC_API_KEY} overrides {@code ai.provider.claude.apiKey}</li>
* </ul>
* Each environment variable is applied only to its own provider family; the variables
* of different families are never mixed.
*
* <h2>Error handling</h2>
* <ul>
* <li>If {@code ai.provider.active} is absent or blank, a {@link ConfigurationLoadingException}
* is thrown.</li>
* <li>If {@code ai.provider.active} holds an unrecognised value, a
* {@link ConfigurationLoadingException} is thrown.</li>
* <li>If a {@code timeoutSeconds} property is present but not a valid integer, a
* {@link ConfigurationLoadingException} is thrown.</li>
* <li>Missing optional fields result in {@code null} (String) or {@code 0} (int) stored in
* the returned record; the validator enforces required fields for the active provider.</li>
* </ul>
*
* <p>The returned {@link MultiProviderConfiguration} is not yet validated. Use
* {@link MultiProviderConfigurationValidator} after parsing.
*/
public class MultiProviderConfigurationParser {
/** Property key selecting the active provider family. */
static final String PROP_ACTIVE_PROVIDER = "ai.provider.active";
static final String PROP_OPENAI_BASE_URL = "ai.provider.openai-compatible.baseUrl";
static final String PROP_OPENAI_MODEL = "ai.provider.openai-compatible.model";
static final String PROP_OPENAI_TIMEOUT = "ai.provider.openai-compatible.timeoutSeconds";
static final String PROP_OPENAI_API_KEY = "ai.provider.openai-compatible.apiKey";
static final String PROP_CLAUDE_BASE_URL = "ai.provider.claude.baseUrl";
static final String PROP_CLAUDE_MODEL = "ai.provider.claude.model";
static final String PROP_CLAUDE_TIMEOUT = "ai.provider.claude.timeoutSeconds";
static final String PROP_CLAUDE_API_KEY = "ai.provider.claude.apiKey";
/** Environment variable for the OpenAI-compatible provider API key. */
static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_API_KEY";
/** Environment variable for the Anthropic Claude provider API key. */
static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_API_KEY";
/** Default base URL for the Anthropic Claude provider when not explicitly configured. */
static final String CLAUDE_DEFAULT_BASE_URL = "https://api.anthropic.com";
private final Function<String, String> environmentLookup;
/**
* Creates a parser that uses the real system environment for API key resolution.
*/
public MultiProviderConfigurationParser() {
this(System::getenv);
}
/**
* Creates a parser with a custom environment lookup function.
* <p>
* This constructor is intended for testing to allow deterministic control over
* environment variable values without modifying the real process environment.
*
* @param environmentLookup a function that maps environment variable names to their values;
* must not be {@code null}
*/
public MultiProviderConfigurationParser(Function<String, String> environmentLookup) {
this.environmentLookup = environmentLookup;
}
/**
* Parses the multi-provider configuration from the given properties.
* <p>
* The Claude default base URL ({@code https://api.anthropic.com}) is applied when
* {@code ai.provider.claude.baseUrl} is absent. API keys are resolved with environment
* variable precedence. The resulting configuration is not yet validated; call
* {@link MultiProviderConfigurationValidator#validate(MultiProviderConfiguration)} afterward.
*
* @param props the properties to parse; must not be {@code null}
* @return the parsed (but not yet validated) multi-provider configuration
* @throws ConfigurationLoadingException if {@code ai.provider.active} is absent, blank,
* or holds an unrecognised value, or if any present timeout property is not a
* valid integer
*/
public MultiProviderConfiguration parse(Properties props) {
AiProviderFamily activeFamily = parseActiveProvider(props);
ProviderConfiguration openAiConfig = parseOpenAiCompatibleConfig(props);
ProviderConfiguration claudeConfig = parseClaudeConfig(props);
return new MultiProviderConfiguration(activeFamily, openAiConfig, claudeConfig);
}
private AiProviderFamily parseActiveProvider(Properties props) {
String raw = props.getProperty(PROP_ACTIVE_PROVIDER);
if (raw == null || raw.isBlank()) {
throw new ConfigurationLoadingException(
"Required property missing or blank: " + PROP_ACTIVE_PROVIDER
+ ". Valid values: openai-compatible, claude");
}
String trimmed = raw.trim();
return AiProviderFamily.fromIdentifier(trimmed).orElseThrow(() ->
new ConfigurationLoadingException(
"Unknown provider identifier for " + PROP_ACTIVE_PROVIDER + ": '" + trimmed
+ "'. Valid values: openai-compatible, claude"));
}
private ProviderConfiguration parseOpenAiCompatibleConfig(Properties props) {
String model = getOptionalString(props, PROP_OPENAI_MODEL);
int timeout = parseTimeoutSeconds(props, PROP_OPENAI_TIMEOUT);
String baseUrl = getOptionalString(props, PROP_OPENAI_BASE_URL);
String apiKey = resolveApiKey(props, PROP_OPENAI_API_KEY, ENV_OPENAI_API_KEY);
return new ProviderConfiguration(model, timeout, baseUrl, apiKey);
}
private ProviderConfiguration parseClaudeConfig(Properties props) {
String model = getOptionalString(props, PROP_CLAUDE_MODEL);
int timeout = parseTimeoutSeconds(props, PROP_CLAUDE_TIMEOUT);
String baseUrl = getStringOrDefault(props, PROP_CLAUDE_BASE_URL, CLAUDE_DEFAULT_BASE_URL);
String apiKey = resolveApiKey(props, PROP_CLAUDE_API_KEY, ENV_CLAUDE_API_KEY);
return new ProviderConfiguration(model, timeout, baseUrl, apiKey);
}
/**
* Returns the trimmed property value, or {@code null} if absent or blank.
*/
private String getOptionalString(Properties props, String key) {
String value = props.getProperty(key);
return (value == null || value.isBlank()) ? null : value.trim();
}
/**
* Returns the trimmed property value, or the {@code defaultValue} if absent or blank.
*/
private String getStringOrDefault(Properties props, String key, String defaultValue) {
String value = props.getProperty(key);
return (value == null || value.isBlank()) ? defaultValue : value.trim();
}
/**
* Parses a timeout property as a positive integer.
* <p>
* Returns {@code 0} when the property is absent or blank (indicating "not configured").
* Throws {@link ConfigurationLoadingException} when the property is present but not
* parseable as an integer.
*/
private int parseTimeoutSeconds(Properties props, String key) {
String value = props.getProperty(key);
if (value == null || value.isBlank()) {
return 0;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
throw new ConfigurationLoadingException(
"Invalid integer value for property " + key + ": '" + value.trim() + "'", e);
}
}
/**
* Resolves the effective API key for a provider family.
* <p>
* The environment variable value takes precedence over the properties value.
* If the environment variable is absent or blank, the properties value is used.
* If both are absent or blank, an empty string is returned (the validator will
* reject this for the active provider).
*
* @param props the configuration properties
* @param propertyKey the property key for the API key of this provider family
* @param envVarName the environment variable name for this provider family
* @return the resolved API key; never {@code null}, but may be blank
*/
private String resolveApiKey(Properties props, String propertyKey, String envVarName) {
String envValue = environmentLookup.apply(envVarName);
if (envValue != null && !envValue.isBlank()) {
return envValue.trim();
}
String propsValue = props.getProperty(propertyKey);
return (propsValue != null) ? propsValue.trim() : "";
}
}

View File

@@ -0,0 +1,106 @@
package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import java.util.ArrayList;
import java.util.List;
/**
* Validates a {@link MultiProviderConfiguration} before the application run begins.
* <p>
* Enforces all requirements for the active provider:
* <ul>
* <li>{@code ai.provider.active} refers to a recognised provider family.</li>
* <li>{@code model} is non-blank.</li>
* <li>{@code timeoutSeconds} is a positive integer.</li>
* <li>{@code baseUrl} is non-blank (required for the OpenAI-compatible family;
* the Claude family always has a default).</li>
* <li>{@code apiKey} is non-blank after environment-variable precedence has been applied
* by {@link MultiProviderConfigurationParser}.</li>
* </ul>
* Required fields of the <em>inactive</em> provider are intentionally not enforced.
* <p>
* Validation errors are aggregated and reported together in a single
* {@link InvalidStartConfigurationException}.
*/
public class MultiProviderConfigurationValidator {
/**
* Validates the given multi-provider configuration.
* <p>
* Only the active provider's required fields are validated. The inactive provider's
* configuration may be incomplete.
*
* @param config the configuration to validate; must not be {@code null}
* @throws InvalidStartConfigurationException if any validation rule fails, with an aggregated
* message listing all problems found
*/
public void validate(MultiProviderConfiguration config) {
List<String> errors = new ArrayList<>();
validateActiveProvider(config, errors);
if (!errors.isEmpty()) {
throw new InvalidStartConfigurationException(
"Invalid AI provider configuration:\n" + String.join("\n", errors));
}
}
private void validateActiveProvider(MultiProviderConfiguration config, List<String> errors) {
AiProviderFamily activeFamily = config.activeProviderFamily();
if (activeFamily == null) {
// Parser already throws for missing/unknown ai.provider.active,
// but guard defensively in case the record is constructed directly in tests.
errors.add("- ai.provider.active: must be set to a supported provider "
+ "(openai-compatible, claude)");
return;
}
ProviderConfiguration providerConfig = config.activeProviderConfiguration();
String providerLabel = "ai.provider." + activeFamily.getIdentifier();
validateModel(providerConfig, providerLabel, errors);
validateTimeoutSeconds(providerConfig, providerLabel, errors);
validateBaseUrl(activeFamily, providerConfig, providerLabel, errors);
validateApiKey(providerConfig, providerLabel, errors);
}
private void validateModel(ProviderConfiguration config, String providerLabel, List<String> errors) {
if (config.model() == null || config.model().isBlank()) {
errors.add("- " + providerLabel + ".model: must not be blank");
}
}
private void validateTimeoutSeconds(ProviderConfiguration config, String providerLabel,
List<String> errors) {
if (config.timeoutSeconds() <= 0) {
errors.add("- " + providerLabel + ".timeoutSeconds: must be a positive integer, got: "
+ config.timeoutSeconds());
}
}
/**
* Validates base URL presence.
* <p>
* The OpenAI-compatible family requires an explicit base URL.
* The Claude family always has a default ({@code https://api.anthropic.com}) applied by the
* parser, so this check is a safety net rather than a primary enforcement mechanism.
*/
private void validateBaseUrl(AiProviderFamily family, ProviderConfiguration config,
String providerLabel, List<String> errors) {
if (config.baseUrl() == null || config.baseUrl().isBlank()) {
errors.add("- " + providerLabel + ".baseUrl: must not be blank");
}
}
private void validateApiKey(ProviderConfiguration config, String providerLabel,
List<String> errors) {
if (config.apiKey() == null || config.apiKey().isBlank()) {
errors.add("- " + providerLabel + ".apiKey: must not be blank "
+ "(set via environment variable or properties)");
}
}
}

View File

@@ -2,8 +2,6 @@ package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -14,22 +12,24 @@ import java.util.function.Function;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
/**
* Properties-based implementation of {@link ConfigurationPort}.
* <p>
* Loads configuration from config/application.properties as the primary source.
* For sensitive values, environment variables take precedence: if the environment variable
* {@code PDF_UMBENENNER_API_KEY} is set, it overrides the {@code api.key} property from the file.
* This allows credentials to be managed securely without storing them in the configuration file.
* Loads configuration from {@code config/application.properties} as the primary source.
* The multi-provider AI configuration is parsed via {@link MultiProviderConfigurationParser}
* and validated via {@link MultiProviderConfigurationValidator}. Environment variables
* for API keys are resolved by the parser with provider-specific precedence rules:
* {@code OPENAI_COMPATIBLE_API_KEY} for the OpenAI-compatible family and
* {@code ANTHROPIC_API_KEY} for the Anthropic Claude family.
*/
public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
private static final Logger LOG = LogManager.getLogger(PropertiesConfigurationPortAdapter.class);
private static final String DEFAULT_CONFIG_FILE_PATH = "config/application.properties";
private static final String API_KEY_ENV_VAR = "PDF_UMBENENNER_API_KEY";
private final Function<String, String> environmentLookup;
private final Path configFilePath;
@@ -81,8 +81,9 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
@Override
public StartConfiguration loadConfiguration() {
Properties props = loadPropertiesFile();
String apiKey = getApiKey(props);
return buildStartConfiguration(props, apiKey);
MultiProviderConfiguration multiProviderConfig = parseAndValidateProviders(props);
boolean logAiSensitive = parseAiContentSensitivity(props);
return buildStartConfiguration(props, multiProviderConfig, logAiSensitive);
}
private Properties loadPropertiesFile() {
@@ -100,22 +101,28 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
return props;
}
private String escapeBackslashes(String content) {
// Escape backslashes to prevent Java Properties from interpreting them as escape sequences.
// This is needed because Windows paths use backslashes (e.g., C:\temp\...)
// and Java Properties interprets \t as tab, \n as newline, etc.
return content.replace("\\", "\\\\");
/**
* Parses and validates the multi-provider AI configuration from the given properties.
* <p>
* Uses {@link MultiProviderConfigurationParser} for parsing and
* {@link MultiProviderConfigurationValidator} for validation. Throws on any
* configuration error before returning.
*/
private MultiProviderConfiguration parseAndValidateProviders(Properties props) {
MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(environmentLookup);
MultiProviderConfiguration config = parser.parse(props);
new MultiProviderConfigurationValidator().validate(config);
return config;
}
private StartConfiguration buildStartConfiguration(Properties props, String apiKey) {
boolean logAiSensitive = parseAiContentSensitivity(props);
private StartConfiguration buildStartConfiguration(Properties props,
MultiProviderConfiguration multiProviderConfig,
boolean logAiSensitive) {
return new StartConfiguration(
Paths.get(getRequiredProperty(props, "source.folder")),
Paths.get(getRequiredProperty(props, "target.folder")),
Paths.get(getRequiredProperty(props, "sqlite.file")),
parseUri(getRequiredProperty(props, "api.baseUrl")),
getRequiredProperty(props, "api.model"),
parseInt(getRequiredProperty(props, "api.timeoutSeconds")),
multiProviderConfig,
parseInt(getRequiredProperty(props, "max.retries.transient")),
parseInt(getRequiredProperty(props, "max.pages")),
parseInt(getRequiredProperty(props, "max.text.characters")),
@@ -123,19 +130,15 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
Paths.get(getOptionalProperty(props, "runtime.lock.file", "")),
Paths.get(getOptionalProperty(props, "log.directory", "")),
getOptionalProperty(props, "log.level", "INFO"),
apiKey,
logAiSensitive
);
}
private String getApiKey(Properties props) {
String envApiKey = environmentLookup.apply(API_KEY_ENV_VAR);
if (envApiKey != null && !envApiKey.isBlank()) {
LOG.info("Using API key from environment variable {}", API_KEY_ENV_VAR);
return envApiKey;
}
String propsApiKey = props.getProperty("api.key");
return propsApiKey != null ? propsApiKey : "";
private String escapeBackslashes(String content) {
// Escape backslashes to prevent Java Properties from interpreting them as escape sequences.
// This is needed because Windows paths use backslashes (e.g., C:\temp\...)
// and Java Properties interprets \t as tab, \n as newline, etc.
return content.replace("\\", "\\\\");
}
private String getRequiredProperty(Properties props, String key) {
@@ -169,14 +172,6 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
}
}
private URI parseUri(String value) {
try {
return new URI(value.trim());
} catch (URISyntaxException e) {
throw new ConfigurationLoadingException("Invalid URI value for property: " + value, e);
}
}
/**
* Parses the {@code log.ai.sensitive} configuration property with strict validation.
* <p>
@@ -212,4 +207,4 @@ public class PropertiesConfigurationPortAdapter implements ConfigurationPort {
+ "Default is 'false' (sensitive content not logged).");
}
}
}
}

View File

@@ -31,9 +31,9 @@ import de.gecheckt.pdf.umbenenner.domain.model.RunId;
* including all AI traceability fields added during schema evolution.
* <p>
* <strong>Schema compatibility:</strong> This adapter writes all columns including
* the AI traceability columns. When reading rows that were written before schema
* evolution, those columns contain {@code NULL} and are mapped to {@code null}
* in the Java record.
* the AI traceability columns and the provider-identifier column ({@code ai_provider}).
* When reading rows that were written before schema evolution, those columns contain
* {@code NULL} and are mapped to {@code null} in the Java record.
* <p>
* <strong>Architecture boundary:</strong> All JDBC and SQLite details are strictly
* confined to this class. No JDBC types appear in the port interface or in any
@@ -129,6 +129,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
failure_class,
failure_message,
retryable,
ai_provider,
model_name,
prompt_identifier,
processed_page_count,
@@ -139,7 +140,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
date_source,
validated_title,
final_target_file_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection connection = getConnection();
@@ -157,19 +158,20 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
setNullableString(statement, 7, attempt.failureClass());
setNullableString(statement, 8, attempt.failureMessage());
statement.setBoolean(9, attempt.retryable());
// AI traceability fields
setNullableString(statement, 10, attempt.modelName());
setNullableString(statement, 11, attempt.promptIdentifier());
setNullableInteger(statement, 12, attempt.processedPageCount());
setNullableInteger(statement, 13, attempt.sentCharacterCount());
setNullableString(statement, 14, attempt.aiRawResponse());
setNullableString(statement, 15, attempt.aiReasoning());
setNullableString(statement, 16,
attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
// AI provider identifier and AI traceability fields
setNullableString(statement, 10, attempt.aiProvider());
setNullableString(statement, 11, attempt.modelName());
setNullableString(statement, 12, attempt.promptIdentifier());
setNullableInteger(statement, 13, attempt.processedPageCount());
setNullableInteger(statement, 14, attempt.sentCharacterCount());
setNullableString(statement, 15, attempt.aiRawResponse());
setNullableString(statement, 16, attempt.aiReasoning());
setNullableString(statement, 17,
attempt.resolvedDate() != null ? attempt.resolvedDate().toString() : null);
setNullableString(statement, 18,
attempt.dateSource() != null ? attempt.dateSource().name() : null);
setNullableString(statement, 18, attempt.validatedTitle());
setNullableString(statement, 19, attempt.finalTargetFileName());
setNullableString(statement, 19, attempt.validatedTitle());
setNullableString(statement, 20, attempt.finalTargetFileName());
int rowsAffected = statement.executeUpdate();
if (rowsAffected != 1) {
@@ -204,7 +206,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
@@ -255,7 +257,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
SELECT
fingerprint, run_id, attempt_number, started_at, ended_at,
status, failure_class, failure_message, retryable,
model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_provider, model_name, prompt_identifier, processed_page_count, sent_character_count,
ai_raw_response, ai_reasoning, resolved_date, date_source, validated_title,
final_target_file_name
FROM processing_attempt
@@ -312,6 +314,7 @@ public class SqliteProcessingAttemptRepositoryAdapter implements ProcessingAttem
rs.getString("failure_class"),
rs.getString("failure_message"),
rs.getBoolean("retryable"),
rs.getString("ai_provider"),
rs.getString("model_name"),
rs.getString("prompt_identifier"),
processedPageCount,

View File

@@ -41,6 +41,9 @@ import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitiali
* <li>Target-copy columns ({@code last_target_path}, {@code last_target_file_name}) to
* {@code document_record}</li>
* <li>Target-copy column ({@code final_target_file_name}) to {@code processing_attempt}</li>
* <li>Provider-identifier column ({@code ai_provider}) to {@code processing_attempt};
* existing rows receive {@code NULL} as the default, which is the correct value for
* attempts recorded before provider tracking was introduced.</li>
* </ul>
*
* <h2>Legacy-state migration</h2>
@@ -150,6 +153,9 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
/**
* Columns to add idempotently to {@code processing_attempt}.
* Each entry is {@code [column_name, column_type]}.
* <p>
* {@code ai_provider} is nullable; existing rows receive {@code NULL}, which is the
* correct sentinel for attempts recorded before provider tracking was introduced.
*/
private static final String[][] EVOLUTION_ATTEMPT_COLUMNS = {
{"model_name", "TEXT"},
@@ -162,6 +168,7 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
{"date_source", "TEXT"},
{"validated_title", "TEXT"},
{"final_target_file_name", "TEXT"},
{"ai_provider", "TEXT"},
};
// -------------------------------------------------------------------------
@@ -229,7 +236,8 @@ public class SqliteSchemaInitializationAdapter implements PersistenceSchemaIniti
* <li>Create {@code document_record} table (if not exists).</li>
* <li>Create {@code processing_attempt} table (if not exists).</li>
* <li>Create all indexes (if not exist).</li>
* <li>Add AI-traceability columns to {@code processing_attempt} (idempotent evolution).</li>
* <li>Add AI-traceability and provider-identifier columns to {@code processing_attempt}
* (idempotent evolution).</li>
* <li>Migrate earlier positive intermediate state to {@code READY_FOR_AI} (idempotent).</li>
* </ol>
* <p>

View File

@@ -0,0 +1,211 @@
package de.gecheckt.pdf.umbenenner.adapter.out.ai;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.adapter.out.clock.SystemClockAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.fingerprint.Sha256FingerprintAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.lock.FilesystemRunLockPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.pdfextraction.PdfTextExtractionPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.prompt.FilesystemPromptPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sourcedocument.SourceDocumentCandidatesPortAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteDocumentRecordRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteProcessingAttemptRepositoryAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteUnitOfWorkAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetcopy.FilesystemTargetFileCopyAdapter;
import de.gecheckt.pdf.umbenenner.adapter.out.targetfolder.FilesystemTargetFolderAdapter;
import de.gecheckt.pdf.umbenenner.application.config.RuntimeConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiContentSensitivity;
import de.gecheckt.pdf.umbenenner.application.port.out.FingerprintSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingLogger;
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.application.usecase.DefaultBatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentCandidate;
import de.gecheckt.pdf.umbenenner.domain.model.SourceDocumentLocator;
/**
* Integration test verifying that the Anthropic Claude adapter integrates correctly
* with the full batch processing pipeline and that the provider identifier
* {@code "claude"} is persisted in the processing attempt history.
* <p>
* Uses a mocked HTTP client to simulate the Anthropic API without real network calls.
* All other adapters (SQLite, filesystem, PDF extraction, fingerprinting) are real
* production implementations.
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("AnthropicClaudeAdapter integration")
class AnthropicClaudeAdapterIntegrationTest {
/**
* Pflicht-Testfall 15: claudeProviderIdentifierLandsInAttemptHistory
* <p>
* Verifies the end-to-end integration: the Claude adapter with a mocked HTTP layer
* is wired into the batch pipeline, and after a successful run, the processing attempt
* record contains {@code ai_provider='claude'}.
*/
@Test
@DisplayName("claudeProviderIdentifierLandsInAttemptHistory: ai_provider=claude in attempt history after successful run")
@SuppressWarnings("unchecked")
void claudeProviderIdentifierLandsInAttemptHistory(@TempDir Path tempDir) throws Exception {
// --- Infrastructure setup ---
Path sourceFolder = Files.createDirectories(tempDir.resolve("source"));
Path targetFolder = Files.createDirectories(tempDir.resolve("target"));
Path promptFile = tempDir.resolve("prompt.txt");
Files.writeString(promptFile, "Analysiere das Dokument und liefere JSON.");
String jdbcUrl = "jdbc:sqlite:" + tempDir.resolve("test.db")
.toAbsolutePath().toString().replace('\\', '/');
new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
// --- Create a searchable PDF in the source folder ---
Path pdfPath = sourceFolder.resolve("testdokument.pdf");
createSearchablePdf(pdfPath, "Testinhalt Rechnung Datum 15.01.2024 Betrag 99 EUR");
// --- Compute fingerprint for later verification ---
Sha256FingerprintAdapter fingerprintAdapter = new Sha256FingerprintAdapter();
SourceDocumentCandidate candidate = new SourceDocumentCandidate(
pdfPath.getFileName().toString(), 0L,
new SourceDocumentLocator(pdfPath.toAbsolutePath().toString()));
DocumentFingerprint fingerprint = switch (fingerprintAdapter.computeFingerprint(candidate)) {
case FingerprintSuccess s -> s.fingerprint();
default -> throw new IllegalStateException("Fingerprint computation failed");
};
// --- Mock the HTTP client for the Claude adapter ---
HttpClient mockHttpClient = mock(HttpClient.class);
// Build a valid Anthropic response with the NamingProposal JSON as text content
String namingProposalJson =
"{\\\"date\\\":\\\"2024-01-15\\\",\\\"title\\\":\\\"Testrechnung\\\","
+ "\\\"reasoning\\\":\\\"Rechnung vom 15.01.2024\\\"}";
String anthropicResponseBody = "{"
+ "\"id\":\"msg_integration_test\","
+ "\"type\":\"message\","
+ "\"role\":\"assistant\","
+ "\"content\":[{\"type\":\"text\",\"text\":\"" + namingProposalJson + "\"}],"
+ "\"stop_reason\":\"end_turn\""
+ "}";
HttpResponse<String> mockHttpResponse = mock(HttpResponse.class);
when(mockHttpResponse.statusCode()).thenReturn(200);
when(mockHttpResponse.body()).thenReturn(anthropicResponseBody);
when(mockHttpClient.send(any(HttpRequest.class), any()))
.thenReturn((HttpResponse) mockHttpResponse);
// --- Create the Claude adapter with the mocked HTTP client ---
ProviderConfiguration claudeConfig = new ProviderConfiguration(
"claude-3-5-sonnet-20241022", 60, "https://api.anthropic.com", "sk-ant-test");
AnthropicClaudeHttpAdapter claudeAdapter =
new AnthropicClaudeHttpAdapter(claudeConfig, mockHttpClient);
// --- Wire the full pipeline with provider identifier "claude" ---
SqliteDocumentRecordRepositoryAdapter documentRepo =
new SqliteDocumentRecordRepositoryAdapter(jdbcUrl);
SqliteProcessingAttemptRepositoryAdapter attemptRepo =
new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
SqliteUnitOfWorkAdapter unitOfWork = new SqliteUnitOfWorkAdapter(jdbcUrl);
ProcessingLogger noOpLogger = new NoOpProcessingLogger();
DocumentProcessingCoordinator coordinator = new DocumentProcessingCoordinator(
documentRepo, attemptRepo, unitOfWork,
new FilesystemTargetFolderAdapter(targetFolder),
new FilesystemTargetFileCopyAdapter(targetFolder),
noOpLogger,
3,
"claude"); // provider identifier for Claude
AiNamingService aiNamingService = new AiNamingService(
claudeAdapter,
new FilesystemPromptPortAdapter(promptFile),
new AiResponseValidator(new SystemClockAdapter()),
"claude-3-5-sonnet-20241022",
10_000);
DefaultBatchRunProcessingUseCase useCase = new DefaultBatchRunProcessingUseCase(
new RuntimeConfiguration(50, 3, AiContentSensitivity.PROTECT_SENSITIVE_CONTENT),
new FilesystemRunLockPortAdapter(tempDir.resolve("run.lock")),
new SourceDocumentCandidatesPortAdapter(sourceFolder),
new PdfTextExtractionPortAdapter(),
fingerprintAdapter,
coordinator,
aiNamingService,
noOpLogger);
// --- Run the batch ---
BatchRunContext context = new BatchRunContext(
new RunId(UUID.randomUUID().toString()), Instant.now());
useCase.execute(context);
// --- Verify: ai_provider='claude' is stored in the attempt history ---
List<ProcessingAttempt> attempts = attemptRepo.findAllByFingerprint(fingerprint);
assertThat(attempts)
.as("At least one attempt must be recorded")
.isNotEmpty();
assertThat(attempts.get(0).aiProvider())
.as("Provider identifier must be 'claude' in the attempt history")
.isEqualTo("claude");
}
// =========================================================================
// Helpers
// =========================================================================
/**
* Creates a single-page searchable PDF with embedded text using PDFBox.
*/
private static void createSearchablePdf(Path pdfPath, String text) throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
try (PDPageContentStream cs = new PDPageContentStream(doc, page)) {
cs.beginText();
cs.setFont(new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12);
cs.newLineAtOffset(50, 700);
cs.showText(text);
cs.endText();
}
doc.save(pdfPath.toFile());
}
}
/**
* No-op implementation of {@link ProcessingLogger} for use in integration tests
* where log output is not relevant to the assertion.
*/
private static class NoOpProcessingLogger implements ProcessingLogger {
@Override public void info(String message, Object... args) {}
@Override public void debug(String message, Object... args) {}
@Override public void warn(String message, Object... args) {}
@Override public void error(String message, Object... args) {}
@Override public void debugSensitiveAiContent(String message, Object... args) {}
}
}

View File

@@ -0,0 +1,643 @@
package de.gecheckt.pdf.umbenenner.adapter.out.ai;
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.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.ConnectException;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.MultiProviderConfigurationValidator;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
/**
* Unit tests for {@link AnthropicClaudeHttpAdapter}.
* <p>
* Tests inject a mock {@link HttpClient} via the package-private constructor
* to exercise the adapter path without requiring network access.
* Configuration is supplied via {@link ProviderConfiguration}.
* <p>
* Covered scenarios:
* <ul>
* <li>Correct HTTP request structure (URL, method, headers, body)</li>
* <li>API key resolution (env var vs. properties value)</li>
* <li>Configuration validation for missing API key</li>
* <li>Single and multiple text-block extraction from Anthropic response</li>
* <li>Ignoring non-text content blocks</li>
* <li>Technical failure when no text blocks are present</li>
* <li>HTTP 4xx (401, 429) and 5xx (500) mapped to technical failure</li>
* <li>Timeout mapped to technical failure</li>
* <li>Unparseable JSON response mapped to technical failure</li>
* </ul>
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("AnthropicClaudeHttpAdapter")
class AnthropicClaudeHttpAdapterTest {
private static final String API_BASE_URL = "https://api.anthropic.com";
private static final String API_MODEL = "claude-3-5-sonnet-20241022";
private static final String API_KEY = "sk-ant-test-key-12345";
private static final int TIMEOUT_SECONDS = 60;
@Mock
private HttpClient httpClient;
private ProviderConfiguration testConfiguration;
private AnthropicClaudeHttpAdapter adapter;
@BeforeEach
void setUp() {
testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
adapter = new AnthropicClaudeHttpAdapter(testConfiguration, httpClient);
}
// =========================================================================
// Pflicht-Testfall 1: claudeAdapterBuildsCorrectRequest
// =========================================================================
/**
* Verifies that the adapter constructs the correct HTTP request:
* URL with {@code /v1/messages} path, method POST, all three required headers
* ({@code x-api-key}, {@code anthropic-version}, {@code content-type}), and
* a body with {@code model}, {@code max_tokens > 0}, and {@code messages} containing
* exactly one user message with the document text.
*/
@Test
@DisplayName("claudeAdapterBuildsCorrectRequest: correct URL, method, headers, and body")
void claudeAdapterBuildsCorrectRequest() throws Exception {
HttpResponse<String> httpResponse = mockHttpResponse(200, buildAnthropicSuccessResponse(
"{\"date\":\"2024-01-15\",\"title\":\"Testititel\",\"reasoning\":\"Test\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiRequestRepresentation request = createTestRequest("System-Prompt", "Dokumenttext");
adapter.invoke(request);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
// URL must point to /v1/messages
assertThat(capturedRequest.uri().toString())
.as("URL must be based on configured baseUrl")
.startsWith(API_BASE_URL)
.endsWith("/v1/messages");
// Method must be POST
assertThat(capturedRequest.method()).isEqualTo("POST");
// All three required headers must be present
assertThat(capturedRequest.headers().firstValue("x-api-key"))
.as("x-api-key header must be present")
.isPresent();
assertThat(capturedRequest.headers().firstValue("anthropic-version"))
.as("anthropic-version header must be present")
.isPresent()
.hasValue("2023-06-01");
assertThat(capturedRequest.headers().firstValue("content-type"))
.as("content-type header must be present")
.isPresent();
// Body must contain model, max_tokens > 0, and messages with one user message
String sentBody = adapter.getLastBuiltJsonBodyForTesting();
JSONObject body = new JSONObject(sentBody);
assertThat(body.getString("model"))
.as("model must match configuration")
.isEqualTo(API_MODEL);
assertThat(body.getInt("max_tokens"))
.as("max_tokens must be positive")
.isGreaterThan(0);
assertThat(body.getJSONArray("messages").length())
.as("messages must contain exactly one entry")
.isEqualTo(1);
assertThat(body.getJSONArray("messages").getJSONObject(0).getString("role"))
.as("the single message must be a user message")
.isEqualTo("user");
assertThat(body.getJSONArray("messages").getJSONObject(0).getString("content"))
.as("user message content must be the document text")
.isEqualTo("Dokumenttext");
}
// =========================================================================
// Pflicht-Testfall 2: claudeAdapterUsesEnvVarApiKey
// =========================================================================
/**
* Verifies that when the {@code ANTHROPIC_API_KEY} environment variable is the source
* of the resolved API key (represented in ProviderConfiguration after env-var precedence
* was applied by the configuration layer), the adapter uses that key in the
* {@code x-api-key} header.
*/
@Test
@DisplayName("claudeAdapterUsesEnvVarApiKey: env var value reaches x-api-key header")
void claudeAdapterUsesEnvVarApiKey() throws Exception {
String envVarValue = "sk-ant-from-env-variable";
// Env var takes precedence: the configuration layer resolves this into apiKey
ProviderConfiguration configWithEnvKey = new ProviderConfiguration(
API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, envVarValue);
AnthropicClaudeHttpAdapter adapterWithEnvKey =
new AnthropicClaudeHttpAdapter(configWithEnvKey, httpClient);
HttpResponse<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapterWithEnvKey.invoke(createTestRequest("prompt", "doc"));
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
assertThat(requestCaptor.getValue().headers().firstValue("x-api-key"))
.as("x-api-key header must contain the env var value")
.hasValue(envVarValue);
}
// =========================================================================
// Pflicht-Testfall 3: claudeAdapterFallsBackToPropertiesApiKey
// =========================================================================
/**
* Verifies that when no environment variable is set, the API key from the
* properties configuration is used in the {@code x-api-key} header.
*/
@Test
@DisplayName("claudeAdapterFallsBackToPropertiesApiKey: properties key reaches x-api-key header")
void claudeAdapterFallsBackToPropertiesApiKey() throws Exception {
String propertiesKey = "sk-ant-from-properties";
ProviderConfiguration configWithPropertiesKey = new ProviderConfiguration(
API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, propertiesKey);
AnthropicClaudeHttpAdapter adapterWithPropertiesKey =
new AnthropicClaudeHttpAdapter(configWithPropertiesKey, httpClient);
HttpResponse<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapterWithPropertiesKey.invoke(createTestRequest("prompt", "doc"));
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
assertThat(requestCaptor.getValue().headers().firstValue("x-api-key"))
.as("x-api-key header must contain the properties value")
.hasValue(propertiesKey);
}
// =========================================================================
// Pflicht-Testfall 4: claudeAdapterFailsValidationWhenBothKeysMissing
// =========================================================================
/**
* Verifies that when both the environment variable and the properties API key for the
* Claude provider are empty, the {@link MultiProviderConfigurationValidator} rejects the
* configuration with an {@link InvalidStartConfigurationException}.
* <p>
* This confirms that the adapter is protected by startup validation (from AP-001)
* and will never be constructed with a truly missing API key in production.
*/
@Test
@DisplayName("claudeAdapterFailsValidationWhenBothKeysMissing: validator rejects empty API key for Claude")
void claudeAdapterFailsValidationWhenBothKeysMissing() {
// Simulate both env var and properties key being absent (empty resolved key)
ProviderConfiguration claudeConfigWithoutKey = new ProviderConfiguration(
API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, "");
ProviderConfiguration inactiveOpenAiConfig = new ProviderConfiguration(
"unused-model", 0, null, null);
MultiProviderConfiguration config = new MultiProviderConfiguration(
AiProviderFamily.CLAUDE, inactiveOpenAiConfig, claudeConfigWithoutKey);
MultiProviderConfigurationValidator validator = new MultiProviderConfigurationValidator();
assertThatThrownBy(() -> validator.validate(config))
.as("Validator must reject Claude configuration with empty API key")
.isInstanceOf(InvalidStartConfigurationException.class);
}
// =========================================================================
// Pflicht-Testfall 5: claudeAdapterParsesSingleTextBlock
// =========================================================================
/**
* Verifies that a response with a single text block is correctly extracted.
*/
@Test
@DisplayName("claudeAdapterParsesSingleTextBlock: single text block becomes raw response")
void claudeAdapterParsesSingleTextBlock() throws Exception {
String blockText = "{\"date\":\"2024-01-15\",\"title\":\"Rechnung\",\"reasoning\":\"Test\"}";
String responseBody = buildAnthropicSuccessResponse(blockText);
HttpResponse<String> httpResponse = mockHttpResponse(200, responseBody);
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationSuccess.class);
AiInvocationSuccess success = (AiInvocationSuccess) result;
assertThat(success.rawResponse().content())
.as("Raw response must equal the text block content")
.isEqualTo(blockText);
}
// =========================================================================
// Pflicht-Testfall 6: claudeAdapterConcatenatesMultipleTextBlocks
// =========================================================================
/**
* Verifies that multiple text blocks are concatenated in order.
*/
@Test
@DisplayName("claudeAdapterConcatenatesMultipleTextBlocks: text blocks are concatenated in order")
void claudeAdapterConcatenatesMultipleTextBlocks() throws Exception {
String part1 = "Erster Teil der Antwort. ";
String part2 = "Zweiter Teil der Antwort.";
// Build the response using JSONObject to ensure correct escaping
JSONObject block1 = new JSONObject();
block1.put("type", "text");
block1.put("text", part1);
JSONObject block2 = new JSONObject();
block2.put("type", "text");
block2.put("text", part2);
JSONObject responseJson = new JSONObject();
responseJson.put("id", "msg_test");
responseJson.put("type", "message");
responseJson.put("role", "assistant");
responseJson.put("content", new JSONArray().put(block1).put(block2));
responseJson.put("stop_reason", "end_turn");
HttpResponse<String> httpResponse = mockHttpResponse(200, responseJson.toString());
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationSuccess.class);
assertThat(((AiInvocationSuccess) result).rawResponse().content())
.as("Multiple text blocks must be concatenated in order")
.isEqualTo(part1 + part2);
}
// =========================================================================
// Pflicht-Testfall 7: claudeAdapterIgnoresNonTextBlocks
// =========================================================================
/**
* Verifies that non-text content blocks (e.g., tool_use) are ignored and only
* the text blocks contribute to the raw response.
*/
@Test
@DisplayName("claudeAdapterIgnoresNonTextBlocks: only text-type blocks contribute to response")
void claudeAdapterIgnoresNonTextBlocks() throws Exception {
String textContent = "Nur dieser Text zaehlt als Antwort.";
// Build response with a tool_use block before and a tool_result-like block after the text block
JSONObject toolUseBlock = new JSONObject();
toolUseBlock.put("type", "tool_use");
toolUseBlock.put("id", "tool_1");
toolUseBlock.put("name", "get_weather");
toolUseBlock.put("input", new JSONObject());
JSONObject textBlock = new JSONObject();
textBlock.put("type", "text");
textBlock.put("text", textContent);
JSONObject ignoredBlock = new JSONObject();
ignoredBlock.put("type", "tool_result");
ignoredBlock.put("content", "irrelevant");
JSONObject responseJson = new JSONObject();
responseJson.put("id", "msg_test");
responseJson.put("type", "message");
responseJson.put("role", "assistant");
responseJson.put("content", new JSONArray().put(toolUseBlock).put(textBlock).put(ignoredBlock));
responseJson.put("stop_reason", "end_turn");
HttpResponse<String> httpResponse = mockHttpResponse(200, responseJson.toString());
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationSuccess.class);
assertThat(((AiInvocationSuccess) result).rawResponse().content())
.as("Only text-type blocks must contribute to the raw response")
.isEqualTo(textContent);
}
// =========================================================================
// Pflicht-Testfall 8: claudeAdapterFailsOnEmptyTextContent
// =========================================================================
/**
* Verifies that a response with no text-type content blocks results in a
* technical failure.
*/
@Test
@DisplayName("claudeAdapterFailsOnEmptyTextContent: no text blocks yields technical failure")
void claudeAdapterFailsOnEmptyTextContent() throws Exception {
String noTextBlockResponse = "{"
+ "\"id\":\"msg_test\","
+ "\"type\":\"message\","
+ "\"role\":\"assistant\","
+ "\"content\":["
+ "{\"type\":\"tool_use\",\"id\":\"tool_1\",\"name\":\"unused\",\"input\":{}}"
+ "],"
+ "\"stop_reason\":\"tool_use\""
+ "}";
HttpResponse<String> httpResponse = mockHttpResponse(200, noTextBlockResponse);
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason())
.isEqualTo("NO_TEXT_CONTENT");
}
// =========================================================================
// Pflicht-Testfall 9: claudeAdapterMapsHttp401AsTechnical
// =========================================================================
/**
* Verifies that HTTP 401 (Unauthorized) is classified as a technical failure.
*/
@Test
@DisplayName("claudeAdapterMapsHttp401AsTechnical: HTTP 401 yields technical failure")
void claudeAdapterMapsHttp401AsTechnical() throws Exception {
HttpResponse<String> httpResponse = mockHttpResponse(401, null);
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_401");
}
// =========================================================================
// Pflicht-Testfall 10: claudeAdapterMapsHttp429AsTechnical
// =========================================================================
/**
* Verifies that HTTP 429 (Rate Limit Exceeded) is classified as a technical failure.
*/
@Test
@DisplayName("claudeAdapterMapsHttp429AsTechnical: HTTP 429 yields technical failure")
void claudeAdapterMapsHttp429AsTechnical() throws Exception {
HttpResponse<String> httpResponse = mockHttpResponse(429, null);
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_429");
}
// =========================================================================
// Pflicht-Testfall 11: claudeAdapterMapsHttp500AsTechnical
// =========================================================================
/**
* Verifies that HTTP 500 (Internal Server Error) is classified as a technical failure.
*/
@Test
@DisplayName("claudeAdapterMapsHttp500AsTechnical: HTTP 500 yields technical failure")
void claudeAdapterMapsHttp500AsTechnical() throws Exception {
HttpResponse<String> httpResponse = mockHttpResponse(500, null);
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_500");
}
// =========================================================================
// Pflicht-Testfall 12: claudeAdapterMapsTimeoutAsTechnical
// =========================================================================
/**
* Verifies that a simulated HTTP timeout results in a technical failure with
* reason {@code TIMEOUT}.
*/
@Test
@DisplayName("claudeAdapterMapsTimeoutAsTechnical: timeout yields TIMEOUT technical failure")
void claudeAdapterMapsTimeoutAsTechnical() throws Exception {
when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new HttpTimeoutException("Connection timed out"));
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("TIMEOUT");
}
// =========================================================================
// Pflicht-Testfall 13: claudeAdapterMapsUnparseableJsonAsTechnical
// =========================================================================
/**
* Verifies that a non-JSON response body (e.g., an HTML error page or plain text)
* returned with HTTP 200 results in a technical failure.
*/
@Test
@DisplayName("claudeAdapterMapsUnparseableJsonAsTechnical: non-JSON body yields technical failure")
void claudeAdapterMapsUnparseableJsonAsTechnical() throws Exception {
HttpResponse<String> httpResponse = mockHttpResponse(200,
"<html><body>Service unavailable</body></html>");
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("UNPARSEABLE_JSON");
}
// =========================================================================
// Additional behavioral tests
// =========================================================================
@Test
@DisplayName("should use configured model in request body")
void testConfiguredModelIsUsedInRequestBody() throws Exception {
HttpResponse<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapter.invoke(createTestRequest("prompt", "doc"));
String sentBody = adapter.getLastBuiltJsonBodyForTesting();
assertThat(new JSONObject(sentBody).getString("model")).isEqualTo(API_MODEL);
}
@Test
@DisplayName("should use configured timeout in request")
void testConfiguredTimeoutIsUsedInRequest() throws Exception {
HttpResponse<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapter.invoke(createTestRequest("prompt", "doc"));
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
assertThat(requestCaptor.getValue().timeout())
.isPresent()
.get()
.isEqualTo(Duration.ofSeconds(TIMEOUT_SECONDS));
}
@Test
@DisplayName("should place prompt content in system field and document text in user message")
void testPromptContentGoesToSystemFieldDocumentTextToUserMessage() throws Exception {
HttpResponse<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
String promptContent = "Du bist ein Assistent zur Dokumentenbenennung.";
String documentText = "Rechnungstext des Dokuments.";
adapter.invoke(createTestRequest(promptContent, documentText));
String sentBody = adapter.getLastBuiltJsonBodyForTesting();
JSONObject body = new JSONObject(sentBody);
assertThat(body.getString("system"))
.as("Prompt content must be placed in the top-level system field")
.isEqualTo(promptContent);
assertThat(body.getJSONArray("messages").getJSONObject(0).getString("content"))
.as("Document text must be placed in the user message content")
.isEqualTo(documentText);
}
@Test
@DisplayName("should map CONNECTION_ERROR when ConnectException is thrown")
void testConnectionExceptionIsMappedToConnectionError() throws Exception {
when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new ConnectException("Connection refused"));
AiInvocationResult result = adapter.invoke(createTestRequest("p", "d"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("CONNECTION_ERROR");
}
@Test
@DisplayName("should map DNS_ERROR when UnknownHostException is thrown")
void testUnknownHostExceptionIsMappedToDnsError() throws Exception {
when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new UnknownHostException("api.anthropic.com"));
AiInvocationResult result = adapter.invoke(createTestRequest("p", "d"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("DNS_ERROR");
}
@Test
@DisplayName("should throw NullPointerException when request is null")
void testNullRequestThrowsException() {
assertThatThrownBy(() -> adapter.invoke(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("request must not be null");
}
@Test
@DisplayName("should throw NullPointerException when configuration is null")
void testNullConfigurationThrowsException() {
assertThatThrownBy(() -> new AnthropicClaudeHttpAdapter(null, httpClient))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("config must not be null");
}
@Test
@DisplayName("should throw IllegalArgumentException when API model is blank")
void testBlankApiModelThrowsException() {
ProviderConfiguration invalidConfig = new ProviderConfiguration(
" ", TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
assertThatThrownBy(() -> new AnthropicClaudeHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("API model must not be null or empty");
}
@Test
@DisplayName("should use default base URL when baseUrl is null")
void testDefaultBaseUrlUsedWhenNull() throws Exception {
ProviderConfiguration configWithoutBaseUrl = new ProviderConfiguration(
API_MODEL, TIMEOUT_SECONDS, null, API_KEY);
AnthropicClaudeHttpAdapter adapterWithDefault =
new AnthropicClaudeHttpAdapter(configWithoutBaseUrl, httpClient);
HttpResponse<String> httpResponse = mockHttpResponse(200,
buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
adapterWithDefault.invoke(createTestRequest("p", "d"));
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
assertThat(requestCaptor.getValue().uri().toString())
.as("Default base URL https://api.anthropic.com must be used when baseUrl is null")
.startsWith("https://api.anthropic.com");
}
// =========================================================================
// Helper methods
// =========================================================================
/**
* Builds a minimal valid Anthropic Messages API response body with a single text block.
*/
private static String buildAnthropicSuccessResponse(String textContent) {
// Escape the textContent for embedding in JSON string
String escaped = textContent
.replace("\\", "\\\\")
.replace("\"", "\\\"");
return "{"
+ "\"id\":\"msg_test\","
+ "\"type\":\"message\","
+ "\"role\":\"assistant\","
+ "\"content\":[{\"type\":\"text\",\"text\":\"" + escaped + "\"}],"
+ "\"stop_reason\":\"end_turn\""
+ "}";
}
@SuppressWarnings("unchecked")
private HttpResponse<String> mockHttpResponse(int statusCode, String body) {
HttpResponse<String> response = (HttpResponse<String>) mock(HttpResponse.class);
when(response.statusCode()).thenReturn(statusCode);
if (body != null) {
when(response.body()).thenReturn(body);
}
return response;
}
private AiRequestRepresentation createTestRequest(String promptContent, String documentText) {
return new AiRequestRepresentation(
new PromptIdentifier("test-v1"),
promptContent,
documentText,
documentText.length()
);
}
}

View File

@@ -5,13 +5,11 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.net.ConnectException;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.file.Paths;
import java.time.Duration;
import org.junit.jupiter.api.BeforeEach;
@@ -25,11 +23,10 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.json.JSONArray;
import org.json.JSONObject;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
@@ -39,6 +36,7 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
* <strong>Test strategy:</strong>
* Tests inject a mock {@link HttpClient} via the package-private constructor
* to exercise the real HTTP adapter path without requiring network access.
* Configuration is supplied via {@link ProviderConfiguration}.
* <p>
* <strong>Coverage goals:</strong>
* <ul>
@@ -56,6 +54,8 @@ import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;
* <li>Effective API key is actually used in the Authorization header</li>
* <li>Full document text is sent (not truncated)</li>
* <li>Null request raises NullPointerException</li>
* <li>Adapter reads all values from ProviderConfiguration (AP-003)</li>
* <li>Behavioral contracts are unchanged after constructor change (AP-003)</li>
* </ul>
*/
@ExtendWith(MockitoExtension.class)
@@ -70,28 +70,12 @@ class OpenAiHttpAdapterTest {
@Mock
private HttpClient httpClient;
private StartConfiguration testConfiguration;
private ProviderConfiguration testConfiguration;
private OpenAiHttpAdapter adapter;
@BeforeEach
void setUp() {
testConfiguration = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
API_MODEL,
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY,
false
);
testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
// Use the package-private constructor with injected mock HttpClient
adapter = new OpenAiHttpAdapter(testConfiguration, httpClient);
}
@@ -242,7 +226,6 @@ class OpenAiHttpAdapterTest {
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
// Verify the timeout was actually configured on the request
assertThat(capturedRequest.timeout())
.as("HttpRequest timeout should be present")
.isPresent()
@@ -437,23 +420,8 @@ class OpenAiHttpAdapterTest {
@Test
@DisplayName("should throw IllegalArgumentException when API base URL is null")
void testNullApiBaseUrlThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
null, // Invalid: null base URL
API_MODEL,
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY,
false
);
ProviderConfiguration invalidConfig = new ProviderConfiguration(
API_MODEL, TIMEOUT_SECONDS, null, API_KEY);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class)
@@ -463,23 +431,8 @@ class OpenAiHttpAdapterTest {
@Test
@DisplayName("should throw IllegalArgumentException when API model is null")
void testNullApiModelThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
null, // Invalid: null model
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY,
false
);
ProviderConfiguration invalidConfig = new ProviderConfiguration(
null, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class)
@@ -489,23 +442,8 @@ class OpenAiHttpAdapterTest {
@Test
@DisplayName("should throw IllegalArgumentException when API model is blank")
void testBlankApiModelThrowsException() {
StartConfiguration invalidConfig = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
" ", // Invalid: blank model
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
API_KEY,
false
);
ProviderConfiguration invalidConfig = new ProviderConfiguration(
" ", TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
.isInstanceOf(IllegalArgumentException.class)
@@ -516,25 +454,9 @@ class OpenAiHttpAdapterTest {
@DisplayName("should handle empty API key gracefully")
void testEmptyApiKeyHandled() throws Exception {
// Arrange
StartConfiguration configWithEmptyKey = new StartConfiguration(
Paths.get("/source"),
Paths.get("/target"),
Paths.get("/db.sqlite"),
URI.create(API_BASE_URL),
API_MODEL,
TIMEOUT_SECONDS,
5,
100,
5000,
Paths.get("/prompt.txt"),
Paths.get("/lock"),
Paths.get("/logs"),
"INFO",
"", // Empty key
false
);
OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(configWithEmptyKey, httpClient);
OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(
new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, ""),
httpClient);
HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
@@ -548,18 +470,94 @@ class OpenAiHttpAdapterTest {
assertThat(result).isInstanceOf(AiInvocationSuccess.class);
}
// =========================================================================
// Mandatory AP-003 test cases
// =========================================================================
/**
* Verifies that the adapter reads all values from the new {@link ProviderConfiguration}
* namespace and uses them correctly in outgoing HTTP requests.
*/
@Test
@DisplayName("openAiAdapterReadsValuesFromNewNamespace: all ProviderConfiguration fields are used")
void openAiAdapterReadsValuesFromNewNamespace() throws Exception {
// Arrange: ProviderConfiguration with values distinct from setUp defaults
ProviderConfiguration nsConfig = new ProviderConfiguration(
"ns-model-v2", 20, "https://provider-ns.example.com", "ns-api-key-abc");
OpenAiHttpAdapter nsAdapter = new OpenAiHttpAdapter(nsConfig, httpClient);
HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
when(httpClient.send(any(HttpRequest.class), any())).thenReturn((HttpResponse) httpResponse);
AiRequestRepresentation request = createTestRequest("prompt", "document");
nsAdapter.invoke(request);
ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(requestCaptor.capture(), any());
HttpRequest capturedRequest = requestCaptor.getValue();
// Verify baseUrl from ProviderConfiguration
assertThat(capturedRequest.uri().toString())
.as("baseUrl must come from ProviderConfiguration")
.startsWith("https://provider-ns.example.com");
// Verify apiKey from ProviderConfiguration
assertThat(capturedRequest.headers().firstValue("Authorization").orElse(""))
.as("apiKey must come from ProviderConfiguration")
.contains("ns-api-key-abc");
// Verify model from ProviderConfiguration
String body = nsAdapter.getLastBuiltJsonBodyForTesting();
assertThat(new JSONObject(body).getString("model"))
.as("model must come from ProviderConfiguration")
.isEqualTo("ns-model-v2");
// Verify timeout from ProviderConfiguration
assertThat(capturedRequest.timeout())
.as("timeout must come from ProviderConfiguration")
.isPresent()
.get()
.isEqualTo(Duration.ofSeconds(20));
}
/**
* Verifies that adapter behavioral contracts (success mapping, error classification)
* are unchanged after the constructor was changed from StartConfiguration to
* ProviderConfiguration.
*/
@Test
@DisplayName("openAiAdapterBehaviorIsUnchanged: HTTP success and error mapping contracts are preserved")
void openAiAdapterBehaviorIsUnchanged() throws Exception {
// Success case: HTTP 200 must produce AiInvocationSuccess with raw body
String successBody = "{\"choices\":[{\"message\":{\"content\":\"result\"}}]}";
HttpResponse<String> successResponse = mockHttpResponse(200, successBody);
when(httpClient.send(any(HttpRequest.class), any()))
.thenReturn((HttpResponse) successResponse);
AiInvocationResult result = adapter.invoke(createTestRequest("p", "d"));
assertThat(result).isInstanceOf(AiInvocationSuccess.class);
assertThat(((AiInvocationSuccess) result).rawResponse().content()).isEqualTo(successBody);
// Non-200 case: HTTP 429 must produce AiInvocationTechnicalFailure with HTTP_429 reason
HttpResponse<String> rateLimitedResponse = mockHttpResponse(429, null);
when(httpClient.send(any(HttpRequest.class), any()))
.thenReturn((HttpResponse) rateLimitedResponse);
result = adapter.invoke(createTestRequest("p", "d"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_429");
// Timeout case: HttpTimeoutException must produce TIMEOUT reason
when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new HttpTimeoutException("timed out"));
result = adapter.invoke(createTestRequest("p", "d"));
assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("TIMEOUT");
}
// Helper methods
/**
* Creates a mock HttpResponse with the specified status code and optional body.
* <p>
* This helper method works around Mockito's type variance issues with generics
* by creating the mock with proper type handling. If body is null, the body()
* method is not stubbed to avoid unnecessary stubs.
*
* @param statusCode the HTTP status code
* @param body the response body (null to skip body stubbing)
* @return a mock HttpResponse configured with the given status and body
*/
@SuppressWarnings("unchecked")
private HttpResponse<String> mockHttpResponse(int statusCode, String body) {

View File

@@ -1,10 +1,12 @@
package de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -23,6 +25,13 @@ class StartConfigurationValidatorTest {
@TempDir
Path tempDir;
/** Helper: builds a minimal valid multi-provider configuration for use in tests. */
private static MultiProviderConfiguration validMultiProviderConfig() {
ProviderConfiguration openAiConfig = new ProviderConfiguration(
"gpt-4", 30, "https://api.example.com", "test-key");
return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
}
@Test
void validate_successWithValidConfiguration() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -34,9 +43,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -44,7 +51,6 @@ class StartConfigurationValidatorTest {
tempDir.resolve("lock.lock"),
tempDir.resolve("logs"),
"INFO",
"test-api-key",
false
);
@@ -57,9 +63,7 @@ class StartConfigurationValidatorTest {
null,
tempDir.resolve("target"),
tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -67,7 +71,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -84,9 +87,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("source"),
null,
tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -94,7 +95,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -111,9 +111,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("source"),
tempDir.resolve("target"),
null,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -121,7 +119,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -133,7 +130,7 @@ class StartConfigurationValidatorTest {
}
@Test
void validate_failsWhenApiBaseUrlIsNull() throws Exception {
void validate_failsWhenMultiProviderConfigurationIsNull() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
@@ -144,8 +141,6 @@ class StartConfigurationValidatorTest {
targetFolder,
sqliteFile,
null,
"gpt-4",
30,
3,
100,
50000,
@@ -153,7 +148,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -161,39 +155,7 @@ class StartConfigurationValidatorTest {
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.baseUrl: must not be null"));
}
@Test
void validate_failsWhenApiModelIsNull() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
null,
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key",
false
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
assertTrue(exception.getMessage().contains("ai provider configuration: must not be null"));
}
@Test
@@ -206,9 +168,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -216,7 +176,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -227,38 +186,6 @@ class StartConfigurationValidatorTest {
assertTrue(exception.getMessage().contains("prompt.template.file: must not be null"));
}
@Test
void validate_failsWhenApiTimeoutSecondsIsZeroOrNegative() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
0,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key",
false
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.timeoutSeconds: must be > 0"));
}
@Test
void validate_failsWhenMaxRetriesTransientIsNegative() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -270,9 +197,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
-1,
100,
50000,
@@ -280,7 +205,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -302,9 +226,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
0,
100,
50000,
@@ -312,7 +234,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -335,9 +256,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
0,
50000,
@@ -345,7 +264,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -367,9 +285,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
-1,
@@ -377,7 +293,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -399,9 +314,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
1, // maxRetriesTransient = 1 is the minimum valid value
100,
50000,
@@ -409,7 +322,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -427,9 +339,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
0, // maxTextCharacters = 0 ist ungültig
@@ -437,7 +347,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -458,9 +367,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("nonexistent"),
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -468,7 +375,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -490,9 +396,7 @@ class StartConfigurationValidatorTest {
sourceFile,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -500,7 +404,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -513,8 +416,6 @@ class StartConfigurationValidatorTest {
@Test
void validate_succeedsWhenTargetFolderDoesNotExistButParentExists() throws Exception {
// target.folder is "anlegbar" (creatable): parent tempDir exists, folder itself does not.
// The validator must create the folder and accept the configuration.
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
@@ -523,9 +424,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
tempDir.resolve("nonexistent-target"),
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -533,7 +432,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -545,7 +443,6 @@ class StartConfigurationValidatorTest {
@Test
void validate_failsWhenTargetFolderCannotBeCreated() {
// Inject a TargetFolderChecker that simulates a creation failure.
StartConfigurationValidator validatorWithFailingChecker = new StartConfigurationValidator(
path -> null, // source folder checker always passes
path -> "- target.folder: path does not exist and could not be created: " + path + " (Permission denied)"
@@ -555,9 +452,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("source"),
tempDir.resolve("uncreatable-target"),
tempDir.resolve("db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -565,7 +460,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -588,9 +482,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFile,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -598,7 +490,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -619,9 +510,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
tempDir.resolve("nonexistent/db.sqlite"),
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -629,7 +518,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -640,70 +528,6 @@ class StartConfigurationValidatorTest {
assertTrue(exception.getMessage().contains("sqlite.file: parent directory does not exist"));
}
@Test
void validate_failsWhenApiBaseUrlIsNotAbsolute() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("/api/v1"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key",
false
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.baseUrl: must be an absolute URI"));
}
@Test
void validate_failsWhenApiBaseUrlHasUnsupportedScheme() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("ftp://api.example.com"),
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key",
false
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.baseUrl: scheme must be http or https"));
}
@Test
void validate_failsWhenPromptTemplateFileDoesNotExist() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -714,9 +538,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -724,7 +546,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -746,9 +567,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -756,7 +575,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -777,9 +595,7 @@ class StartConfigurationValidatorTest {
sameFolder,
sameFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -787,7 +603,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -805,8 +620,6 @@ class StartConfigurationValidatorTest {
null,
null,
null,
null,
0,
-1,
0,
-1,
@@ -814,7 +627,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -826,22 +638,13 @@ class StartConfigurationValidatorTest {
assertTrue(message.contains("source.folder: must not be null"));
assertTrue(message.contains("target.folder: must not be null"));
assertTrue(message.contains("sqlite.file: must not be null"));
assertTrue(message.contains("api.baseUrl: must not be null"));
assertTrue(message.contains("api.model: must not be null or blank"));
assertTrue(message.contains("ai provider configuration: must not be null"));
assertTrue(message.contains("prompt.template.file: must not be null"));
assertTrue(message.contains("api.timeoutSeconds: must be > 0"));
assertTrue(message.contains("max.retries.transient: must be >= 1"));
assertTrue(message.contains("max.pages: must be > 0"));
assertTrue(message.contains("max.text.characters: must be > 0"));
}
/**
* Focused tests for source folder validation using mocked filesystem checks.
* <p>
* These tests verify the four critical paths for source folder validation without
* relying on platform-dependent filesystem permissions or the actual FS state.
*/
@Test
void validate_failsWhenSourceFolderDoesNotExist_mocked() throws Exception {
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
@@ -852,9 +655,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("nonexistent"),
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -862,11 +663,9 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
// Mock: always return "does not exist" error for any path
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: path does not exist: " + path;
@@ -889,9 +688,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("somepath"),
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -899,11 +696,9 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
// Mock: simulate path exists but is not a directory
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: path is not a directory: " + path;
@@ -926,9 +721,7 @@ class StartConfigurationValidatorTest {
tempDir.resolve("somepath"),
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -936,12 +729,9 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
// Mock: simulate path exists, is directory, but is not readable
// This is the critical case that is hard to test on actual FS
StartConfigurationValidator.SourceFolderChecker mockChecker = path ->
"- source.folder: directory is not readable: " + path;
@@ -965,9 +755,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -975,11 +763,9 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
// Mock: all checks pass (return null)
StartConfigurationValidator.SourceFolderChecker mockChecker = path -> null;
StartConfigurationValidator validatorWithMock = new StartConfigurationValidator(mockChecker);
@@ -988,24 +774,19 @@ class StartConfigurationValidatorTest {
"Validation should succeed when source folder checker returns null");
}
// Neue Tests zur Verbesserung der Abdeckung
@Test
void validate_failsWhenSqliteFileHasNoParent() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
// Ein Pfad ohne Parent (z.B. einfacher Dateiname)
Path sqliteFileWithoutParent = Path.of("db.sqlite");
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFileWithoutParent,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -1013,7 +794,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -1029,8 +809,7 @@ class StartConfigurationValidatorTest {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
// Erstelle eine Datei und versuche dann, eine Unterdatei davon zu erstellen
Path parentFile = Files.createFile(tempDir.resolve("parentFile.txt"));
Path sqliteFileWithFileAsParent = parentFile.resolve("db.sqlite");
@@ -1038,9 +817,7 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFileWithFileAsParent,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
@@ -1048,7 +825,6 @@ class StartConfigurationValidatorTest {
null,
null,
"INFO",
"test-api-key",
false
);
@@ -1059,70 +835,6 @@ class StartConfigurationValidatorTest {
assertTrue(exception.getMessage().contains("sqlite.file: parent is not a directory"));
}
@Test
void validate_apiModelBlankString() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
" ", // Blank string
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key",
false
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
}
@Test
void validate_apiModelEmptyString() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"", // Empty string
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key",
false
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
assertTrue(exception.getMessage().contains("api.model: must not be null or blank"));
}
@Test
void validate_runtimeLockFileParentDoesNotExist() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
@@ -1134,17 +846,14 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
promptTemplateFile,
tempDir.resolve("nonexistent/lock.lock"), // Lock file mit nicht existierendem Parent
tempDir.resolve("nonexistent/lock.lock"),
null,
"INFO",
"test-api-key",
false
);
@@ -1161,8 +870,7 @@ class StartConfigurationValidatorTest {
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
// Erstelle eine Datei und versuche dann, eine Unterdatei davon zu erstellen
Path parentFile = Files.createFile(tempDir.resolve("parentFile.txt"));
Path lockFileWithFileAsParent = parentFile.resolve("lock.lock");
@@ -1170,17 +878,14 @@ class StartConfigurationValidatorTest {
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
promptTemplateFile,
lockFileWithFileAsParent, // Lock file mit Datei als Parent
lockFileWithFileAsParent,
null,
"INFO",
"test-api-key",
false
);
@@ -1197,25 +902,21 @@ class StartConfigurationValidatorTest {
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
// Erstelle eine Datei, die als Log-Verzeichnis verwendet wird
Path logFileInsteadOfDirectory = Files.createFile(tempDir.resolve("logfile.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("https://api.example.com"),
"gpt-4",
30,
validMultiProviderConfig(),
3,
100,
50000,
promptTemplateFile,
null,
logFileInsteadOfDirectory, // Datei statt Verzeichnis
logFileInsteadOfDirectory,
"INFO",
"test-api-key",
false
);
@@ -1225,66 +926,4 @@ class StartConfigurationValidatorTest {
);
assertTrue(exception.getMessage().contains("log.directory: exists but is not a directory"));
}
@Test
void validate_apiBaseUrlHttpScheme() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("http://api.example.com"), // HTTP statt HTTPS
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key",
false
);
assertDoesNotThrow(() -> validator.validate(config),
"HTTP scheme should be valid");
}
@Test
void validate_apiBaseUrlNullScheme() throws Exception {
Path sourceFolder = Files.createDirectory(tempDir.resolve("source"));
Path targetFolder = Files.createDirectory(tempDir.resolve("target"));
Path sqliteFile = Files.createFile(tempDir.resolve("db.sqlite"));
Path promptTemplateFile = Files.createFile(tempDir.resolve("prompt.txt"));
StartConfiguration config = new StartConfiguration(
sourceFolder,
targetFolder,
sqliteFile,
URI.create("//api.example.com"), // Kein Schema
"gpt-4",
30,
3,
100,
50000,
promptTemplateFile,
null,
null,
"INFO",
"test-api-key",
false
);
InvalidStartConfigurationException exception = assertThrows(
InvalidStartConfigurationException.class,
() -> validator.validate(config)
);
// Bei einer URI ohne Schema ist sie nicht absolut, daher kommt zuerst diese Fehlermeldung
assertTrue(exception.getMessage().contains("api.baseUrl: must be an absolute URI"));
}
}
}

View File

@@ -0,0 +1,351 @@
package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests for {@link LegacyConfigurationMigrator}.
* <p>
* Covers all mandatory test cases for the legacy-to-multi-provider configuration migration.
* Temporary files are managed via {@link TempDir} so no test artifacts remain on the file system.
*/
class LegacyConfigurationMigratorTest {
@TempDir
Path tempDir;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/** Full legacy configuration containing all four api.* keys plus other required keys. */
private static String fullLegacyContent() {
return "source.folder=./source\n"
+ "target.folder=./target\n"
+ "sqlite.file=./db.sqlite\n"
+ "api.baseUrl=https://api.openai.com/v1\n"
+ "api.model=gpt-4o\n"
+ "api.timeoutSeconds=30\n"
+ "max.retries.transient=3\n"
+ "max.pages=10\n"
+ "max.text.characters=5000\n"
+ "prompt.template.file=./prompt.txt\n"
+ "api.key=sk-test-legacy-key\n"
+ "log.level=INFO\n"
+ "log.ai.sensitive=false\n";
}
private Path writeLegacyFile(String name, String content) throws IOException {
Path file = tempDir.resolve(name);
Files.writeString(file, content, StandardCharsets.UTF_8);
return file;
}
private Properties loadProperties(Path file) throws IOException {
Properties props = new Properties();
props.load(Files.newBufferedReader(file, StandardCharsets.UTF_8));
return props;
}
private LegacyConfigurationMigrator defaultMigrator() {
return new LegacyConfigurationMigrator();
}
// =========================================================================
// Mandatory test case 1
// =========================================================================
/**
* Legacy file with all four {@code api.*} keys is correctly migrated.
* Values in the migrated file must be identical to the originals; all other keys survive.
*/
@Test
void migratesLegacyFileWithAllFlatKeys() throws IOException {
Path file = writeLegacyFile("app.properties", fullLegacyContent());
defaultMigrator().migrateIfLegacy(file);
Properties migrated = loadProperties(file);
assertEquals("https://api.openai.com/v1", migrated.getProperty("ai.provider.openai-compatible.baseUrl"));
assertEquals("gpt-4o", migrated.getProperty("ai.provider.openai-compatible.model"));
assertEquals("30", migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"));
assertEquals("sk-test-legacy-key", migrated.getProperty("ai.provider.openai-compatible.apiKey"));
assertEquals("openai-compatible", migrated.getProperty("ai.provider.active"));
// Old flat keys must be gone
assertFalse(migrated.containsKey("api.baseUrl"), "api.baseUrl must be removed");
assertFalse(migrated.containsKey("api.model"), "api.model must be removed");
assertFalse(migrated.containsKey("api.timeoutSeconds"), "api.timeoutSeconds must be removed");
assertFalse(migrated.containsKey("api.key"), "api.key must be removed");
}
// =========================================================================
// Mandatory test case 2
// =========================================================================
/**
* A {@code .bak} backup is created with the exact original content before any changes.
*/
@Test
void createsBakBeforeOverwriting() throws IOException {
String original = fullLegacyContent();
Path file = writeLegacyFile("app.properties", original);
Path bakFile = tempDir.resolve("app.properties.bak");
assertFalse(Files.exists(bakFile), "No .bak should exist before migration");
defaultMigrator().migrateIfLegacy(file);
assertTrue(Files.exists(bakFile), ".bak must be created during migration");
assertEquals(original, Files.readString(bakFile, StandardCharsets.UTF_8),
".bak must contain the exact original content");
}
// =========================================================================
// Mandatory test case 3
// =========================================================================
/**
* When {@code .bak} already exists, the new backup is written as {@code .bak.1}.
* Neither the existing {@code .bak} nor the new {@code .bak.1} is overwritten.
*/
@Test
void bakSuffixIsIncrementedIfBakExists() throws IOException {
String original = fullLegacyContent();
Path file = writeLegacyFile("app.properties", original);
// Pre-create .bak with different content
Path existingBak = tempDir.resolve("app.properties.bak");
Files.writeString(existingBak, "# existing bak", StandardCharsets.UTF_8);
defaultMigrator().migrateIfLegacy(file);
// Existing .bak must be untouched
assertEquals("# existing bak", Files.readString(existingBak, StandardCharsets.UTF_8),
"Existing .bak must not be overwritten");
// New backup must be .bak.1 with original content
Path newBak = tempDir.resolve("app.properties.bak.1");
assertTrue(Files.exists(newBak), ".bak.1 must be created when .bak already exists");
assertEquals(original, Files.readString(newBak, StandardCharsets.UTF_8),
".bak.1 must contain the original content");
}
// =========================================================================
// Mandatory test case 4
// =========================================================================
/**
* A file already in the new multi-provider schema triggers no write and no {@code .bak}.
*/
@Test
void noOpForAlreadyMigratedFile() throws IOException {
String newSchema = "ai.provider.active=openai-compatible\n"
+ "ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1\n"
+ "ai.provider.openai-compatible.model=gpt-4o\n"
+ "ai.provider.openai-compatible.timeoutSeconds=30\n"
+ "ai.provider.openai-compatible.apiKey=sk-key\n";
Path file = writeLegacyFile("app.properties", newSchema);
long modifiedBefore = Files.getLastModifiedTime(file).toMillis();
defaultMigrator().migrateIfLegacy(file);
// File must not have been rewritten
assertEquals(modifiedBefore, Files.getLastModifiedTime(file).toMillis(),
"File modification time must not change for already-migrated files");
// No .bak should exist
Path bakFile = tempDir.resolve("app.properties.bak");
assertFalse(Files.exists(bakFile), "No .bak must be created for already-migrated files");
}
// =========================================================================
// Mandatory test case 5
// =========================================================================
/**
* After migration, the new parser and validator load the file without error.
*/
@Test
void reloadAfterMigrationSucceeds() throws IOException {
Path file = writeLegacyFile("app.properties", fullLegacyContent());
defaultMigrator().migrateIfLegacy(file);
// Reload and parse with the new parser+validator — must not throw
Properties props = loadProperties(file);
MultiProviderConfiguration config = assertDoesNotThrow(
() -> new MultiProviderConfigurationParser().parse(props),
"Migrated file must be parseable by MultiProviderConfigurationParser");
assertDoesNotThrow(
() -> new MultiProviderConfigurationValidator().validate(config),
"Migrated file must pass MultiProviderConfigurationValidator");
}
// =========================================================================
// Mandatory test case 6
// =========================================================================
/**
* When post-migration validation fails, a {@link ConfigurationLoadingException} is thrown
* and the {@code .bak} backup is preserved with the original content.
*/
@Test
void migrationFailureKeepsBak() throws IOException {
String original = fullLegacyContent();
Path file = writeLegacyFile("app.properties", original);
// Validator that always rejects
MultiProviderConfigurationValidator failingValidator = new MultiProviderConfigurationValidator() {
@Override
public void validate(MultiProviderConfiguration config) {
throw new InvalidStartConfigurationException("Simulated validation failure");
}
};
LegacyConfigurationMigrator migrator = new LegacyConfigurationMigrator(
new MultiProviderConfigurationParser(), failingValidator);
assertThrows(ConfigurationLoadingException.class,
() -> migrator.migrateIfLegacy(file),
"Migration must throw ConfigurationLoadingException when post-migration validation fails");
// .bak must be preserved with original content
Path bakFile = tempDir.resolve("app.properties.bak");
assertTrue(Files.exists(bakFile), ".bak must be preserved after migration failure");
assertEquals(original, Files.readString(bakFile, StandardCharsets.UTF_8),
".bak content must match the original file content");
}
// =========================================================================
// Mandatory test case 7
// =========================================================================
/**
* A file that contains {@code ai.provider.active} but no legacy {@code api.*} keys
* is not considered legacy and triggers no migration.
*/
@Test
void legacyDetectionRequiresAtLeastOneFlatKey() throws IOException {
String notLegacy = "ai.provider.active=openai-compatible\n"
+ "source.folder=./source\n"
+ "max.pages=10\n";
Path file = writeLegacyFile("app.properties", notLegacy);
Properties props = new Properties();
props.load(Files.newBufferedReader(file, StandardCharsets.UTF_8));
boolean detected = defaultMigrator().isLegacyForm(props);
assertFalse(detected, "File with ai.provider.active and no api.* keys must not be detected as legacy");
}
// =========================================================================
// Mandatory test case 8
// =========================================================================
/**
* The four legacy values land in exactly the target keys in the openai-compatible namespace,
* and {@code ai.provider.active} is set to {@code openai-compatible}.
*/
@Test
void legacyValuesEndUpInOpenAiCompatibleNamespace() throws IOException {
String content = "api.baseUrl=https://legacy.example.com/v1\n"
+ "api.model=legacy-model\n"
+ "api.timeoutSeconds=42\n"
+ "api.key=legacy-key\n"
+ "source.folder=./src\n";
Path file = writeLegacyFile("app.properties", content);
defaultMigrator().migrateIfLegacy(file);
Properties migrated = loadProperties(file);
assertEquals("https://legacy.example.com/v1", migrated.getProperty("ai.provider.openai-compatible.baseUrl"),
"api.baseUrl must map to ai.provider.openai-compatible.baseUrl");
assertEquals("legacy-model", migrated.getProperty("ai.provider.openai-compatible.model"),
"api.model must map to ai.provider.openai-compatible.model");
assertEquals("42", migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"),
"api.timeoutSeconds must map to ai.provider.openai-compatible.timeoutSeconds");
assertEquals("legacy-key", migrated.getProperty("ai.provider.openai-compatible.apiKey"),
"api.key must map to ai.provider.openai-compatible.apiKey");
assertEquals("openai-compatible", migrated.getProperty("ai.provider.active"),
"ai.provider.active must be set to openai-compatible");
}
// =========================================================================
// Mandatory test case 9
// =========================================================================
/**
* Keys unrelated to the legacy api.* set survive the migration with identical values.
*/
@Test
void unrelatedKeysSurviveUnchanged() throws IOException {
String content = "source.folder=./my/source\n"
+ "target.folder=./my/target\n"
+ "sqlite.file=./my/db.sqlite\n"
+ "max.pages=15\n"
+ "max.text.characters=3000\n"
+ "log.level=DEBUG\n"
+ "log.ai.sensitive=false\n"
+ "api.baseUrl=https://api.openai.com/v1\n"
+ "api.model=gpt-4o\n"
+ "api.timeoutSeconds=30\n"
+ "api.key=sk-unrelated-test\n";
Path file = writeLegacyFile("app.properties", content);
defaultMigrator().migrateIfLegacy(file);
Properties migrated = loadProperties(file);
assertEquals("./my/source", migrated.getProperty("source.folder"), "source.folder must be unchanged");
assertEquals("./my/target", migrated.getProperty("target.folder"), "target.folder must be unchanged");
assertEquals("./my/db.sqlite", migrated.getProperty("sqlite.file"), "sqlite.file must be unchanged");
assertEquals("15", migrated.getProperty("max.pages"), "max.pages must be unchanged");
assertEquals("3000", migrated.getProperty("max.text.characters"), "max.text.characters must be unchanged");
assertEquals("DEBUG", migrated.getProperty("log.level"), "log.level must be unchanged");
assertEquals("false", migrated.getProperty("log.ai.sensitive"), "log.ai.sensitive must be unchanged");
}
// =========================================================================
// Mandatory test case 10
// =========================================================================
/**
* Migration writes via a temporary {@code .tmp} file followed by a move/rename.
* After successful migration, no {@code .tmp} file remains, and the original path
* holds the fully migrated content (never partially overwritten).
*/
@Test
void inPlaceWriteIsAtomic() throws IOException {
Path file = writeLegacyFile("app.properties", fullLegacyContent());
Path tmpFile = tempDir.resolve("app.properties.tmp");
defaultMigrator().migrateIfLegacy(file);
// .tmp must have been cleaned up (moved to target, not left behind)
assertFalse(Files.exists(tmpFile),
".tmp file must not exist after migration (must have been moved to target)");
// Target must contain migrated content
Properties migrated = loadProperties(file);
assertTrue(migrated.containsKey("ai.provider.active"),
"Migrated file must contain ai.provider.active (complete write confirmed)");
assertTrue(migrated.containsKey("ai.provider.openai-compatible.model"),
"Migrated file must contain the new namespaced model key (complete write confirmed)");
}
}

View File

@@ -0,0 +1,281 @@
package de.gecheckt.pdf.umbenenner.adapter.out.configuration;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import org.junit.jupiter.api.Test;
import java.util.Properties;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Tests for the multi-provider configuration parsing and validation pipeline.
* <p>
* Covers all mandatory test cases for the new configuration schema as defined
* in the active work package specification.
*/
class MultiProviderConfigurationTest {
private static final Function<String, String> NO_ENV = key -> null;
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private Properties fullOpenAiProperties() {
Properties props = new Properties();
props.setProperty("ai.provider.active", "openai-compatible");
props.setProperty("ai.provider.openai-compatible.baseUrl", "https://api.openai.com");
props.setProperty("ai.provider.openai-compatible.model", "gpt-4o");
props.setProperty("ai.provider.openai-compatible.timeoutSeconds", "30");
props.setProperty("ai.provider.openai-compatible.apiKey", "sk-openai-test");
// Claude side intentionally not set (inactive)
return props;
}
private Properties fullClaudeProperties() {
Properties props = new Properties();
props.setProperty("ai.provider.active", "claude");
props.setProperty("ai.provider.claude.baseUrl", "https://api.anthropic.com");
props.setProperty("ai.provider.claude.model", "claude-3-5-sonnet-20241022");
props.setProperty("ai.provider.claude.timeoutSeconds", "60");
props.setProperty("ai.provider.claude.apiKey", "sk-ant-test");
// OpenAI side intentionally not set (inactive)
return props;
}
private MultiProviderConfiguration parseAndValidate(Properties props,
Function<String, String> envLookup) {
MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(envLookup);
MultiProviderConfiguration config = parser.parse(props);
new MultiProviderConfigurationValidator().validate(config);
return config;
}
private MultiProviderConfiguration parseAndValidate(Properties props) {
return parseAndValidate(props, NO_ENV);
}
// =========================================================================
// Mandatory test case 1
// =========================================================================
/**
* Full new schema with OpenAI-compatible active, all required values present.
* Parser and validator must both succeed.
*/
@Test
void parsesNewSchemaWithOpenAiCompatibleActive() {
MultiProviderConfiguration config = parseAndValidate(fullOpenAiProperties());
assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, config.activeProviderFamily());
assertEquals("gpt-4o", config.openAiCompatibleConfig().model());
assertEquals(30, config.openAiCompatibleConfig().timeoutSeconds());
assertEquals("https://api.openai.com", config.openAiCompatibleConfig().baseUrl());
assertEquals("sk-openai-test", config.openAiCompatibleConfig().apiKey());
}
// =========================================================================
// Mandatory test case 2
// =========================================================================
/**
* Full new schema with Claude active, all required values present.
* Parser and validator must both succeed.
*/
@Test
void parsesNewSchemaWithClaudeActive() {
MultiProviderConfiguration config = parseAndValidate(fullClaudeProperties());
assertEquals(AiProviderFamily.CLAUDE, config.activeProviderFamily());
assertEquals("claude-3-5-sonnet-20241022", config.claudeConfig().model());
assertEquals(60, config.claudeConfig().timeoutSeconds());
assertEquals("https://api.anthropic.com", config.claudeConfig().baseUrl());
assertEquals("sk-ant-test", config.claudeConfig().apiKey());
}
// =========================================================================
// Mandatory test case 3
// =========================================================================
/**
* Claude active, {@code ai.provider.claude.baseUrl} absent.
* The default {@code https://api.anthropic.com} must be applied; validation must pass.
*/
@Test
void claudeBaseUrlDefaultsWhenMissing() {
Properties props = fullClaudeProperties();
props.remove("ai.provider.claude.baseUrl");
MultiProviderConfiguration config = parseAndValidate(props);
assertNotNull(config.claudeConfig().baseUrl(),
"baseUrl must not be null when Claude default is applied");
assertEquals(MultiProviderConfigurationParser.CLAUDE_DEFAULT_BASE_URL,
config.claudeConfig().baseUrl(),
"Default Claude baseUrl must be https://api.anthropic.com");
}
// =========================================================================
// Mandatory test case 4
// =========================================================================
/**
* {@code ai.provider.active} is absent. Parser must throw with a clear message.
*/
@Test
void rejectsMissingActiveProvider() {
Properties props = fullOpenAiProperties();
props.remove("ai.provider.active");
MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
ConfigurationLoadingException ex = assertThrows(
ConfigurationLoadingException.class,
() -> parser.parse(props));
assertTrue(ex.getMessage().contains("ai.provider.active"),
"Error message must reference the missing property");
}
// =========================================================================
// Mandatory test case 5
// =========================================================================
/**
* {@code ai.provider.active=foo} unrecognised value. Parser must throw.
*/
@Test
void rejectsUnknownActiveProvider() {
Properties props = fullOpenAiProperties();
props.setProperty("ai.provider.active", "foo");
MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
ConfigurationLoadingException ex = assertThrows(
ConfigurationLoadingException.class,
() -> parser.parse(props));
assertTrue(ex.getMessage().contains("foo"),
"Error message must include the unrecognised value");
}
// =========================================================================
// Mandatory test case 6
// =========================================================================
/**
* Active provider has a mandatory field blank (model removed). Validation must fail.
*/
@Test
void rejectsMissingMandatoryFieldForActiveProvider() {
Properties props = fullOpenAiProperties();
props.remove("ai.provider.openai-compatible.model");
MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
MultiProviderConfiguration config = parser.parse(props);
InvalidStartConfigurationException ex = assertThrows(
InvalidStartConfigurationException.class,
() -> new MultiProviderConfigurationValidator().validate(config));
assertTrue(ex.getMessage().contains("model"),
"Error message must mention the missing field");
}
// =========================================================================
// Mandatory test case 7
// =========================================================================
/**
* Inactive provider has incomplete configuration (Claude fields missing while OpenAI is active).
* Validation must pass; inactive provider fields are not required.
*/
@Test
void acceptsMissingMandatoryFieldForInactiveProvider() {
// OpenAI active, Claude completely unconfigured
Properties props = fullOpenAiProperties();
// No ai.provider.claude.* keys set
MultiProviderConfiguration config = parseAndValidate(props);
assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, config.activeProviderFamily(),
"Active provider must be openai-compatible");
// Claude config may have null/blank fields no exception expected
}
// =========================================================================
// Mandatory test case 8
// =========================================================================
/**
* Environment variable for the active provider overrides the properties value.
* <p>
* Sub-case A: {@code OPENAI_COMPATIBLE_API_KEY} set, OpenAI active.
* Sub-case B: {@code ANTHROPIC_API_KEY} set, Claude active.
*/
@Test
void envVarOverridesPropertiesApiKeyForActiveProvider() {
// Sub-case A: OpenAI active, OPENAI_COMPATIBLE_API_KEY set
Properties openAiProps = fullOpenAiProperties();
openAiProps.setProperty("ai.provider.openai-compatible.apiKey", "properties-key");
Function<String, String> envWithOpenAiKey = key ->
MultiProviderConfigurationParser.ENV_OPENAI_API_KEY.equals(key)
? "env-openai-key" : null;
MultiProviderConfiguration openAiConfig = parseAndValidate(openAiProps, envWithOpenAiKey);
assertEquals("env-openai-key", openAiConfig.openAiCompatibleConfig().apiKey(),
"Env var must override properties API key for OpenAI-compatible");
// Sub-case B: Claude active, ANTHROPIC_API_KEY set
Properties claudeProps = fullClaudeProperties();
claudeProps.setProperty("ai.provider.claude.apiKey", "properties-key");
Function<String, String> envWithClaudeKey = key ->
MultiProviderConfigurationParser.ENV_CLAUDE_API_KEY.equals(key)
? "env-claude-key" : null;
MultiProviderConfiguration claudeConfig = parseAndValidate(claudeProps, envWithClaudeKey);
assertEquals("env-claude-key", claudeConfig.claudeConfig().apiKey(),
"Env var must override properties API key for Claude");
}
// =========================================================================
// Mandatory test case 9
// =========================================================================
/**
* Environment variable is set only for the inactive provider.
* The active provider must use its own properties value; the inactive provider's
* env var must not affect the active provider's resolved key.
*/
@Test
void envVarOnlyResolvesForActiveProvider() {
// OpenAI is active with a properties apiKey.
// ANTHROPIC_API_KEY is set (for the inactive Claude provider).
// The OpenAI config must use its properties key, not the Anthropic env var.
Properties props = fullOpenAiProperties();
props.setProperty("ai.provider.openai-compatible.apiKey", "openai-properties-key");
Function<String, String> envWithClaudeKeyOnly = key ->
MultiProviderConfigurationParser.ENV_CLAUDE_API_KEY.equals(key)
? "anthropic-env-key" : null;
MultiProviderConfiguration config = parseAndValidate(props, envWithClaudeKeyOnly);
assertEquals("openai-properties-key",
config.openAiCompatibleConfig().apiKey(),
"Active provider (OpenAI) must use its own properties key, "
+ "not the inactive provider's env var");
// The Anthropic env var IS applied to the Claude config (inactive),
// but that does not affect the active provider.
assertEquals("anthropic-env-key",
config.claudeConfig().apiKey(),
"Inactive Claude config should still pick up its own env var");
}
}

View File

@@ -11,6 +11,8 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Function;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -19,7 +21,8 @@ import org.junit.jupiter.api.io.TempDir;
* Unit tests for {@link PropertiesConfigurationPortAdapter}.
* <p>
* Tests cover valid configuration loading, missing mandatory properties,
* invalid property values, and API-key environment variable precedence.
* invalid property values, and API-key environment variable precedence
* for the multi-provider schema.
*/
class PropertiesConfigurationPortAdapterTest {
@@ -42,13 +45,20 @@ class PropertiesConfigurationPortAdapterTest {
var config = adapter.loadConfiguration();
assertNotNull(config);
// Use endsWith to handle platform-specific path separators
assertTrue(config.sourceFolder().toString().endsWith("source"));
assertTrue(config.targetFolder().toString().endsWith("target"));
assertTrue(config.sqliteFile().toString().endsWith("db.sqlite"));
assertEquals("https://api.example.com", config.apiBaseUrl().toString());
assertEquals("gpt-4", config.apiModel());
assertEquals(30, config.apiTimeoutSeconds());
assertNotNull(config.multiProviderConfiguration());
assertEquals(AiProviderFamily.OPENAI_COMPATIBLE,
config.multiProviderConfiguration().activeProviderFamily());
assertEquals("https://api.example.com",
config.multiProviderConfiguration().activeProviderConfiguration().baseUrl());
assertEquals("gpt-4",
config.multiProviderConfiguration().activeProviderConfiguration().model());
assertEquals(30,
config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds());
assertEquals("test-api-key-from-properties",
config.multiProviderConfiguration().activeProviderConfiguration().apiKey());
assertEquals(3, config.maxRetriesTransient());
assertEquals(100, config.maxPages());
assertEquals(50000, config.maxTextCharacters());
@@ -56,57 +66,60 @@ class PropertiesConfigurationPortAdapterTest {
assertTrue(config.runtimeLockFile().toString().endsWith("lock.lock"));
assertTrue(config.logDirectory().toString().endsWith("logs"));
assertEquals("DEBUG", config.logLevel());
assertEquals("test-api-key-from-properties", config.apiKey());
}
@Test
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsAbsent() throws Exception {
void loadConfiguration_rejectsBlankApiKeyWhenAbsentAndNoEnvVar() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.apiKey(), "API key should be empty when not in properties and no env var");
assertThrows(
de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
adapter::loadConfiguration,
"Missing API key must be rejected as invalid configuration");
}
@Test
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsNull() throws Exception {
void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsNull() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
Function<String, String> envLookup = key -> null;
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.apiKey());
assertThrows(
de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
adapter::loadConfiguration,
"Null env var with no properties API key must be rejected as invalid configuration");
}
@Test
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsEmpty() throws Exception {
void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsEmpty() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
Function<String, String> envLookup = key -> "";
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.apiKey(), "Empty env var should fall back to empty string");
assertThrows(
de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
adapter::loadConfiguration,
"Empty env var with no properties API key must be rejected as invalid configuration");
}
@Test
void loadConfiguration_usesPropertiesApiKeyWhenEnvVarIsBlank() throws Exception {
void loadConfiguration_rejectsBlankApiKeyWhenEnvVarIsBlank() throws Exception {
Path configFile = createConfigFile("no-api-key.properties");
Function<String, String> envLookup = key -> " ";
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(envLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("", config.apiKey(), "Blank env var should fall back to empty string");
assertThrows(
de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException.class,
adapter::loadConfiguration,
"Blank env var with no properties API key must be rejected as invalid configuration");
}
@Test
@@ -114,7 +127,7 @@ class PropertiesConfigurationPortAdapterTest {
Path configFile = createConfigFile("valid-config.properties");
Function<String, String> envLookup = key -> {
if ("PDF_UMBENENNER_API_KEY".equals(key)) {
if (MultiProviderConfigurationParser.ENV_OPENAI_API_KEY.equals(key)) {
return "env-api-key-override";
}
return null;
@@ -124,7 +137,9 @@ class PropertiesConfigurationPortAdapterTest {
var config = adapter.loadConfiguration();
assertEquals("env-api-key-override", config.apiKey(), "Environment variable should override properties");
assertEquals("env-api-key-override",
config.multiProviderConfiguration().activeProviderConfiguration().apiKey(),
"Environment variable must override properties API key");
}
@Test
@@ -163,21 +178,22 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=60\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=60\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=5\n" +
"max.pages=200\n" +
"max.text.characters=100000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
"prompt.template.file=/tmp/prompt.txt\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals(60, config.apiTimeoutSeconds());
assertEquals(60, config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds());
assertEquals(5, config.maxRetriesTransient());
assertEquals(200, config.maxPages());
assertEquals(100000, config.maxTextCharacters());
@@ -189,21 +205,24 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds= 45 \n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds= 45 \n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=2\n" +
"max.pages=150\n" +
"max.text.characters=75000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
"prompt.template.file=/tmp/prompt.txt\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals(45, config.apiTimeoutSeconds(), "Whitespace should be trimmed from integer values");
assertEquals(45,
config.multiProviderConfiguration().activeProviderConfiguration().timeoutSeconds(),
"Whitespace should be trimmed from integer values");
}
@Test
@@ -212,14 +231,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=not-a-number\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=not-a-number\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=2\n" +
"max.pages=150\n" +
"max.text.characters=75000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
"prompt.template.file=/tmp/prompt.txt\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
@@ -233,26 +253,28 @@ class PropertiesConfigurationPortAdapterTest {
}
@Test
void loadConfiguration_parsesUriCorrectly() throws Exception {
void loadConfiguration_parsesBaseUrlStringCorrectly() throws Exception {
Path configFile = createInlineConfig(
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com:8080/v1\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com:8080/v1\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
"prompt.template.file=/tmp/prompt.txt\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
var config = adapter.loadConfiguration();
assertEquals("https://api.example.com:8080/v1", config.apiBaseUrl().toString());
assertEquals("https://api.example.com:8080/v1",
config.multiProviderConfiguration().activeProviderConfiguration().baseUrl());
}
@Test
@@ -261,14 +283,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
"prompt.template.file=/tmp/prompt.txt\n"
);
PropertiesConfigurationPortAdapter adapter = new PropertiesConfigurationPortAdapter(emptyEnvLookup, configFile);
@@ -282,26 +305,28 @@ class PropertiesConfigurationPortAdapterTest {
@Test
void allConfigurationFailuresAreClassifiedAsConfigurationLoadingException() throws Exception {
// Verify that file I/O failure uses ConfigurationLoadingException
// File I/O failure
Path nonExistentFile = tempDir.resolve("nonexistent.properties");
PropertiesConfigurationPortAdapter adapter1 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, nonExistentFile);
assertThrows(ConfigurationLoadingException.class, () -> adapter1.loadConfiguration(),
"File I/O failure should throw ConfigurationLoadingException");
// Verify that missing required property uses ConfigurationLoadingException
// Missing required property
Path missingPropFile = createConfigFile("missing-required.properties");
PropertiesConfigurationPortAdapter adapter2 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, missingPropFile);
assertThrows(ConfigurationLoadingException.class, () -> adapter2.loadConfiguration(),
"Missing required property should throw ConfigurationLoadingException");
// Verify that invalid integer value uses ConfigurationLoadingException
// Invalid integer value
Path invalidIntFile = createInlineConfig(
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=invalid\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=invalid\n" +
"ai.provider.openai-compatible.apiKey=key\n" +
"max.retries.transient=2\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
@@ -311,22 +336,20 @@ class PropertiesConfigurationPortAdapterTest {
assertThrows(ConfigurationLoadingException.class, () -> adapter3.loadConfiguration(),
"Invalid integer value should throw ConfigurationLoadingException");
// Verify that invalid URI value uses ConfigurationLoadingException
Path invalidUriFile = createInlineConfig(
// Unknown ai.provider.active value
Path unknownProviderFile = createInlineConfig(
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=not a valid uri\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=unknown-provider\n" +
"max.retries.transient=2\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n"
);
PropertiesConfigurationPortAdapter adapter4 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, invalidUriFile);
PropertiesConfigurationPortAdapter adapter4 = new PropertiesConfigurationPortAdapter(emptyEnvLookup, unknownProviderFile);
assertThrows(ConfigurationLoadingException.class, () -> adapter4.loadConfiguration(),
"Invalid URI value should throw ConfigurationLoadingException");
"Unknown provider identifier should throw ConfigurationLoadingException");
}
@Test
@@ -335,14 +358,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n"
"prompt.template.file=/tmp/prompt.txt\n"
// log.ai.sensitive intentionally omitted
);
@@ -360,14 +384,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n" +
"log.ai.sensitive=true\n"
);
@@ -385,14 +410,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n" +
"log.ai.sensitive=false\n"
);
@@ -410,14 +436,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n" +
"log.ai.sensitive=TRUE\n"
);
@@ -435,14 +462,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n" +
"log.ai.sensitive=FALSE\n"
);
@@ -460,14 +488,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n" +
"log.ai.sensitive=maybe\n"
);
@@ -490,14 +519,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n" +
"log.ai.sensitive=yes\n"
);
@@ -518,14 +548,15 @@ class PropertiesConfigurationPortAdapterTest {
"source.folder=/tmp/source\n" +
"target.folder=/tmp/target\n" +
"sqlite.file=/tmp/db.sqlite\n" +
"api.baseUrl=https://api.example.com\n" +
"api.model=gpt-4\n" +
"api.timeoutSeconds=30\n" +
"ai.provider.active=openai-compatible\n" +
"ai.provider.openai-compatible.baseUrl=https://api.example.com\n" +
"ai.provider.openai-compatible.model=gpt-4\n" +
"ai.provider.openai-compatible.timeoutSeconds=30\n" +
"ai.provider.openai-compatible.apiKey=test-key\n" +
"max.retries.transient=3\n" +
"max.pages=100\n" +
"max.text.characters=50000\n" +
"prompt.template.file=/tmp/prompt.txt\n" +
"api.key=test-key\n" +
"log.ai.sensitive=1\n"
);
@@ -544,7 +575,6 @@ class PropertiesConfigurationPortAdapterTest {
Path sourceResource = Path.of("src/test/resources", resourceName);
Path targetConfigFile = tempDir.resolve("application.properties");
// Copy content from resource file
Files.copy(sourceResource, targetConfigFile);
return targetConfigFile;
}
@@ -556,4 +586,4 @@ class PropertiesConfigurationPortAdapterTest {
}
return configFile;
}
}
}

View File

@@ -0,0 +1,394 @@
package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;
import de.gecheckt.pdf.umbenenner.domain.model.RunId;
/**
* Tests for the additive {@code ai_provider} column in {@code processing_attempt}.
* <p>
* Covers schema migration (idempotency, nullable default for existing rows),
* write/read round-trips for both supported provider identifiers, and
* backward compatibility with databases created before provider tracking was introduced.
*/
class SqliteAttemptProviderPersistenceTest {
private String jdbcUrl;
private SqliteSchemaInitializationAdapter schemaAdapter;
private SqliteProcessingAttemptRepositoryAdapter repository;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
Path dbFile = tempDir.resolve("provider-test.db");
jdbcUrl = "jdbc:sqlite:" + dbFile.toAbsolutePath();
schemaAdapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
repository = new SqliteProcessingAttemptRepositoryAdapter(jdbcUrl);
}
// -------------------------------------------------------------------------
// Schema migration tests
// -------------------------------------------------------------------------
/**
* A fresh database must contain the {@code ai_provider} column after schema initialisation.
*/
@Test
void addsProviderColumnOnFreshDb() throws SQLException {
schemaAdapter.initializeSchema();
assertThat(columnExists("processing_attempt", "ai_provider"))
.as("ai_provider column must exist in processing_attempt after fresh schema init")
.isTrue();
}
/**
* A database that already has the {@code processing_attempt} table without
* {@code ai_provider} (simulating an existing installation before this column was added)
* must receive the column via the idempotent schema evolution.
*/
@Test
void addsProviderColumnOnExistingDbWithoutColumn() throws SQLException {
// Bootstrap schema without the ai_provider column (simulate legacy DB)
createLegacySchema();
assertThat(columnExists("processing_attempt", "ai_provider"))
.as("ai_provider must not be present before evolution")
.isFalse();
// Running initializeSchema must add the column
schemaAdapter.initializeSchema();
assertThat(columnExists("processing_attempt", "ai_provider"))
.as("ai_provider column must be added by schema evolution")
.isTrue();
}
/**
* Running schema initialisation multiple times must not fail and must not change the schema.
*/
@Test
void migrationIsIdempotent() throws SQLException {
schemaAdapter.initializeSchema();
// Second and third init must not throw or change the schema
schemaAdapter.initializeSchema();
schemaAdapter.initializeSchema();
assertThat(columnExists("processing_attempt", "ai_provider"))
.as("Column must still be present after repeated init calls")
.isTrue();
}
/**
* Rows that existed before the {@code ai_provider} column was added must have
* {@code NULL} as the column value, not a non-null default.
*/
@Test
void existingRowsKeepNullProvider() throws SQLException {
// Create legacy schema and insert a row without ai_provider
createLegacySchema();
DocumentFingerprint fp = fingerprint("aa");
insertLegacyDocumentRecord(fp);
insertLegacyAttemptRow(fp, "READY_FOR_AI");
// Now evolve the schema
schemaAdapter.initializeSchema();
// Read the existing row — ai_provider must be NULL
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
assertThat(attempts).hasSize(1);
assertThat(attempts.get(0).aiProvider())
.as("Existing rows must have NULL ai_provider after schema evolution")
.isNull();
}
// -------------------------------------------------------------------------
// Write tests
// -------------------------------------------------------------------------
/**
* A new attempt written with an active OpenAI-compatible provider must
* persist {@code "openai-compatible"} in {@code ai_provider}.
*/
@Test
void newAttemptsWriteOpenAiCompatibleProvider() {
schemaAdapter.initializeSchema();
DocumentFingerprint fp = fingerprint("bb");
insertDocumentRecord(fp);
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
ProcessingAttempt attempt = new ProcessingAttempt(
fp, new RunId("run-oai"), 1, now, now.plusSeconds(1),
ProcessingStatus.READY_FOR_AI,
null, null, false,
"openai-compatible",
null, null, null, null, null, null,
null, null, null, null);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fp);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).aiProvider()).isEqualTo("openai-compatible");
}
/**
* A new attempt written with an active Claude provider must persist
* {@code "claude"} in {@code ai_provider}.
* <p>
* The provider selection is simulated at the data level here; the actual
* Claude adapter is wired in a later step.
*/
@Test
void newAttemptsWriteClaudeProvider() {
schemaAdapter.initializeSchema();
DocumentFingerprint fp = fingerprint("cc");
insertDocumentRecord(fp);
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
ProcessingAttempt attempt = new ProcessingAttempt(
fp, new RunId("run-claude"), 1, now, now.plusSeconds(1),
ProcessingStatus.READY_FOR_AI,
null, null, false,
"claude",
null, null, null, null, null, null,
null, null, null, null);
repository.save(attempt);
List<ProcessingAttempt> saved = repository.findAllByFingerprint(fp);
assertThat(saved).hasSize(1);
assertThat(saved.get(0).aiProvider()).isEqualTo("claude");
}
// -------------------------------------------------------------------------
// Read tests
// -------------------------------------------------------------------------
/**
* The repository must correctly return the persisted provider identifier
* when reading an attempt back from the database.
*/
@Test
void repositoryReadsProviderColumn() {
schemaAdapter.initializeSchema();
DocumentFingerprint fp = fingerprint("dd");
insertDocumentRecord(fp);
Instant now = Instant.now().truncatedTo(ChronoUnit.MICROS);
repository.save(new ProcessingAttempt(
fp, new RunId("run-read"), 1, now, now.plusSeconds(2),
ProcessingStatus.FAILED_RETRYABLE,
"Timeout", "Connection timed out", true,
"openai-compatible",
null, null, null, null, null, null,
null, null, null, null));
List<ProcessingAttempt> loaded = repository.findAllByFingerprint(fp);
assertThat(loaded).hasSize(1);
assertThat(loaded.get(0).aiProvider())
.as("Repository must return the persisted ai_provider value")
.isEqualTo("openai-compatible");
}
/**
* Reading a database that was created without the {@code ai_provider} column
* (a pre-extension database) must succeed; the new field must be empty/null
* for historical attempts.
*/
@Test
void legacyDataReadingDoesNotFail() throws SQLException {
// Set up legacy schema with a row that has no ai_provider column
createLegacySchema();
DocumentFingerprint fp = fingerprint("ee");
insertLegacyDocumentRecord(fp);
insertLegacyAttemptRow(fp, "FAILED_RETRYABLE");
// Evolve schema — now ai_provider column exists but legacy rows have NULL
schemaAdapter.initializeSchema();
// Reading must not throw and must return null for ai_provider
List<ProcessingAttempt> attempts = repository.findAllByFingerprint(fp);
assertThat(attempts).hasSize(1);
assertThat(attempts.get(0).aiProvider())
.as("Legacy attempt (from before provider tracking) must have null aiProvider")
.isNull();
// Other fields must still be readable
assertThat(attempts.get(0).status()).isEqualTo(ProcessingStatus.FAILED_RETRYABLE);
}
/**
* All existing attempt history tests must remain green: the repository
* handles null {@code ai_provider} values transparently without errors.
*/
@Test
void existingHistoryTestsRemainGreen() {
schemaAdapter.initializeSchema();
DocumentFingerprint fp = fingerprint("ff");
insertDocumentRecord(fp);
Instant base = Instant.now().truncatedTo(ChronoUnit.MICROS);
// Save attempt with null provider (as in legacy path or non-AI attempt)
ProcessingAttempt nullProviderAttempt = ProcessingAttempt.withoutAiFields(
fp, new RunId("run-legacy"), 1,
base, base.plusSeconds(1),
ProcessingStatus.FAILED_RETRYABLE,
"Err", "msg", true);
repository.save(nullProviderAttempt);
// Save attempt with explicit provider
ProcessingAttempt withProvider = new ProcessingAttempt(
fp, new RunId("run-new"), 2,
base.plusSeconds(10), base.plusSeconds(11),
ProcessingStatus.READY_FOR_AI,
null, null, false,
"openai-compatible",
null, null, null, null, null, null,
null, null, null, null);
repository.save(withProvider);
List<ProcessingAttempt> all = repository.findAllByFingerprint(fp);
assertThat(all).hasSize(2);
assertThat(all.get(0).aiProvider()).isNull();
assertThat(all.get(1).aiProvider()).isEqualTo("openai-compatible");
// Ordering preserved
assertThat(all.get(0).attemptNumber()).isEqualTo(1);
assertThat(all.get(1).attemptNumber()).isEqualTo(2);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private boolean columnExists(String table, String column) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
DatabaseMetaData meta = conn.getMetaData();
try (ResultSet rs = meta.getColumns(null, null, table, column)) {
return rs.next();
}
}
}
/**
* Creates the base tables that existed before the {@code ai_provider} column was added,
* without running the schema evolution that adds that column.
*/
private void createLegacySchema() throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
Statement stmt = conn.createStatement()) {
stmt.execute("PRAGMA foreign_keys = ON");
stmt.execute("""
CREATE TABLE IF NOT EXISTS document_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
last_known_source_locator TEXT NOT NULL,
last_known_source_file_name TEXT NOT NULL,
overall_status TEXT NOT NULL,
content_error_count INTEGER NOT NULL DEFAULT 0,
transient_error_count INTEGER NOT NULL DEFAULT 0,
last_failure_instant TEXT,
last_success_instant TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
)""");
stmt.execute("""
CREATE TABLE IF NOT EXISTS processing_attempt (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fingerprint TEXT NOT NULL,
run_id TEXT NOT NULL,
attempt_number INTEGER NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT NOT NULL,
status TEXT NOT NULL,
failure_class TEXT,
failure_message TEXT,
retryable INTEGER NOT NULL DEFAULT 0,
model_name TEXT,
prompt_identifier TEXT,
processed_page_count INTEGER,
sent_character_count INTEGER,
ai_raw_response TEXT,
ai_reasoning TEXT,
resolved_date TEXT,
date_source TEXT,
validated_title TEXT,
final_target_file_name TEXT,
CONSTRAINT fk_processing_attempt_fingerprint
FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
CONSTRAINT uq_processing_attempt_fingerprint_number
UNIQUE (fingerprint, attempt_number)
)""");
}
}
private void insertLegacyDocumentRecord(DocumentFingerprint fp) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
PreparedStatement ps = conn.prepareStatement("""
INSERT INTO document_record
(fingerprint, last_known_source_locator, last_known_source_file_name,
overall_status, created_at, updated_at)
VALUES (?, '/tmp/test.pdf', 'test.pdf', 'READY_FOR_AI',
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))""")) {
ps.setString(1, fp.sha256Hex());
ps.executeUpdate();
}
}
private void insertLegacyAttemptRow(DocumentFingerprint fp, String status) throws SQLException {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
PreparedStatement ps = conn.prepareStatement("""
INSERT INTO processing_attempt
(fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
VALUES (?, 'run-legacy', 1, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), ?, 1)""")) {
ps.setString(1, fp.sha256Hex());
ps.setString(2, status);
ps.executeUpdate();
}
}
private void insertDocumentRecord(DocumentFingerprint fp) {
try (Connection conn = DriverManager.getConnection(jdbcUrl);
PreparedStatement ps = conn.prepareStatement("""
INSERT INTO document_record
(fingerprint, last_known_source_locator, last_known_source_file_name,
overall_status, created_at, updated_at)
VALUES (?, '/tmp/test.pdf', 'test.pdf', 'READY_FOR_AI',
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'),
strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))""")) {
ps.setString(1, fp.sha256Hex());
ps.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to insert test document record", e);
}
}
private static DocumentFingerprint fingerprint(String suffix) {
return new DocumentFingerprint(
("0".repeat(64 - suffix.length()) + suffix));
}
}

View File

@@ -391,6 +391,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, runId, 1, startedAt, endedAt,
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"openai-compatible",
"gpt-4o", "prompt-v1.txt",
5, 1234,
"{\"date\":\"2026-03-15\",\"title\":\"Stromabrechnung\",\"reasoning\":\"Invoice date found.\"}",
@@ -434,6 +435,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, runId, 1, now, now.plusSeconds(5),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
"openai-compatible",
"claude-sonnet-4-6", "prompt-v2.txt",
3, 800,
"{\"title\":\"Kontoauszug\",\"reasoning\":\"No date in document.\"}",
@@ -531,6 +533,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-p"), 1, now, now.plusSeconds(2),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
null,
"gpt-4o", "prompt-v1.txt", 2, 500,
"{\"title\":\"Rechnung\",\"reasoning\":\"Found.\"}",
"Found.", date, DateSource.AI_PROVIDED, "Rechnung",
@@ -560,6 +563,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
null,
"model-a", "prompt-v1.txt", 1, 100,
"{}", "First.", LocalDate.of(2026, 1, 1), DateSource.AI_PROVIDED, "TitelEins",
null
@@ -577,6 +581,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-3"), 3, base.plusSeconds(20), base.plusSeconds(21),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
null,
"model-b", "prompt-v2.txt", 2, 200,
"{}", "Second.", LocalDate.of(2026, 2, 2), DateSource.AI_PROVIDED, "TitelZwei",
null
@@ -606,6 +611,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, runId, 1, now, now.plusSeconds(3),
ProcessingStatus.SUCCESS,
null, null, false,
null,
"gpt-4", "prompt-v1.txt", 2, 600,
"{\"title\":\"Rechnung\",\"reasoning\":\"Invoice.\"}",
"Invoice.",
@@ -637,6 +643,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-prop"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
null,
"gpt-4", "prompt-v1.txt", 1, 200,
"{}", "reason",
LocalDate.of(2026, 3, 1), DateSource.AI_PROVIDED,
@@ -667,6 +674,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-1"), 1, base, base.plusSeconds(2),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
null,
"model-a", "prompt-v1.txt", 3, 700,
"{}", "reason.", date, DateSource.AI_PROVIDED, "Bescheid", null
);
@@ -679,7 +687,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
ProcessingStatus.SUCCESS,
null, null, false,
null, null, null, null, null, null,
null, null, null,
null, null, null, null,
"2026-02-10 - Bescheid.pdf"
);
repository.save(successAttempt);
@@ -742,6 +750,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, new RunId("run-p2"), 1, now, now.plusSeconds(1),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
null,
"model-x", "prompt-v1.txt", 1, 50,
"{}", "Reasoning.", LocalDate.of(2026, 1, 15), DateSource.AI_PROVIDED, "Titel",
null
@@ -787,6 +796,7 @@ class SqliteProcessingAttemptRepositoryAdapterTest {
fingerprint, runId, 1, now, now.plusSeconds(5),
ProcessingStatus.PROPOSAL_READY,
null, null, false,
null,
"gpt-4o", "prompt-v1.txt",
3, 750,
fullRawResponse,

View File

@@ -119,7 +119,8 @@ class SqliteSchemaInitializationAdapterTest {
"resolved_date",
"date_source",
"validated_title",
"final_target_file_name"
"final_target_file_name",
"ai_provider"
);
}

View File

@@ -1,11 +1,12 @@
source.folder=/tmp/source
target.folder=/tmp/target
# sqlite.file is missing
api.baseUrl=https://api.example.com
api.model=gpt-4
api.timeoutSeconds=30
ai.provider.active=openai-compatible
ai.provider.openai-compatible.baseUrl=https://api.example.com
ai.provider.openai-compatible.model=gpt-4
ai.provider.openai-compatible.timeoutSeconds=30
ai.provider.openai-compatible.apiKey=test-api-key
max.retries.transient=3
max.pages=100
max.text.characters=50000
prompt.template.file=/tmp/prompt.txt
api.key=test-api-key

View File

@@ -1,10 +1,11 @@
source.folder=/tmp/source
target.folder=/tmp/target
sqlite.file=/tmp/db.sqlite
api.baseUrl=https://api.example.com
api.model=gpt-4
api.timeoutSeconds=30
ai.provider.active=openai-compatible
ai.provider.openai-compatible.baseUrl=https://api.example.com
ai.provider.openai-compatible.model=gpt-4
ai.provider.openai-compatible.timeoutSeconds=30
max.retries.transient=3
max.pages=100
max.text.characters=50000
prompt.template.file=/tmp/prompt.txt
prompt.template.file=/tmp/prompt.txt

View File

@@ -1,9 +1,11 @@
source.folder=/tmp/source
target.folder=/tmp/target
sqlite.file=/tmp/db.sqlite
api.baseUrl=https://api.example.com
api.model=gpt-4
api.timeoutSeconds=30
ai.provider.active=openai-compatible
ai.provider.openai-compatible.baseUrl=https://api.example.com
ai.provider.openai-compatible.model=gpt-4
ai.provider.openai-compatible.timeoutSeconds=30
ai.provider.openai-compatible.apiKey=test-api-key-from-properties
max.retries.transient=3
max.pages=100
max.text.characters=50000
@@ -11,4 +13,3 @@ prompt.template.file=/tmp/prompt.txt
runtime.lock.file=/tmp/lock.lock
log.directory=/tmp/logs
log.level=DEBUG
api.key=test-api-key-from-properties