V1.1 Änderungen
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() : "";
|
||||
}
|
||||
}
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -119,7 +119,8 @@ class SqliteSchemaInitializationAdapterTest {
|
||||
"resolved_date",
|
||||
"date_source",
|
||||
"validated_title",
|
||||
"final_target_file_name"
|
||||
"final_target_file_name",
|
||||
"ai_provider"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user