M11 vollständig abgeschlossen (AP-001 bis AP-007)

- AP-001: Kernobjekte und Port-Verträge (ModelCatalog-Port, sealed
  Result-Typen, ApiKeyOrigin, GUI-Modell- und Meldungs-Records)
- AP-002: Provider-ComboBox, exklusiver Providerbereich,
  zustandsbewahrender Providerwechsel
- AP-003: HTTP-Adapter für Modellabruf (Claude, OpenAI-kompatibel)
  mit vollständigem Error-Mapping und Dispatcher im Bootstrap
- AP-004: Automatischer Modellabruf bei Providerwechsel, Aktion
  "Modelle neu laden", Umschaltung zwischen Modell-ComboBox und
  Modell-Textfeld, Worker-Thread-Kapselung
- AP-005: Automatische Editorvalidierung (Pflichtfelder,
  Warnschwellen max.text.characters, Plausibilitätshinweise
  max.pages, API-Key-Herkunftsauflösung mit Vorrangregel)
- AP-006: Zentraler Meldungsbereich mit vier Severity-Stufen,
  feldnahe rote Fehlermeldungen, API-Key-Herkunftsanzeige
- AP-007: Integrations- und Regressionstests, Timeout-Mapping-Tests,
  Replace-Semantik für wiederholte Modellabruf-Meldungen

Hexagonale Architektur eingehalten, Application- und Domain-Schicht
bleiben infrastrukturfrei. Threadingmodell konsequent umgesetzt.
Naming-Regel und JavaDoc-Standard durchgängig beachtet.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-20 20:31:15 +02:00
parent bbb5c4da3a
commit aa067a3165
59 changed files with 8363 additions and 136 deletions
@@ -0,0 +1,315 @@
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
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.time.Instant;
import java.util.ArrayList;
import java.util.List;
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.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
/**
* Adapter implementing {@link AiModelCatalogPort} for the native Anthropic Claude provider family.
* <p>
* Fetches the list of available Claude models from the Anthropic models endpoint
* ({@code GET {baseUrl}/v1/models}). Authentication uses the Anthropic-specific
* {@code x-api-key} header together with the {@code anthropic-version} header.
* <p>
* <strong>Default base URL:</strong> When the request carries no base URL,
* {@code https://api.anthropic.com} is used automatically.
* <p>
* <strong>Error handling:</strong> All expected error conditions (missing API key,
* HTTP errors, timeouts, parse failures) are returned as specific
* {@link ModelCatalogResult} sub-types. No exception is thrown to the caller.
* <p>
* <strong>Thread safety:</strong> This adapter is stateless. All configuration
* values are read from the {@link ModelCatalogRequest} at call time. Multiple
* threads may call {@link #fetchAvailableModels(ModelCatalogRequest)} concurrently
* without synchronisation.
* <p>
* <strong>Non-goals:</strong>
* <ul>
* <li>Retry logic — the caller is responsible for retry decisions.</li>
* <li>Caching — a fresh HTTP call is made on every invocation.</li>
* <li>Shared implementation with the OpenAI-compatible adapter.</li>
* </ul>
*/
public class ClaudeModelCatalogAdapter implements AiModelCatalogPort {
private static final Logger LOG = LogManager.getLogger(ClaudeModelCatalogAdapter.class);
/** Anthropic models list endpoint path. */
private static final String MODELS_ENDPOINT = "/v1/models";
/** Default base URL for the Anthropic API, applied when the request carries no base URL. */
static final String DEFAULT_BASE_URL = "https://api.anthropic.com";
/** Anthropic-specific authentication header. */
private static final String API_KEY_HEADER = "x-api-key";
/** Required Anthropic API version header name. */
private static final String ANTHROPIC_VERSION_HEADER = "anthropic-version";
/** Required Anthropic API version header value. */
private static final String ANTHROPIC_VERSION_VALUE = "2023-06-01";
private static final String PROVIDER_ID = "claude";
/**
* Creates a new stateless Claude model catalogue adapter.
* <p>
* No configuration is held in the instance. All request parameters are
* supplied per call via {@link ModelCatalogRequest}.
*/
public ClaudeModelCatalogAdapter() {
// stateless — no fields to initialise
}
/**
* Fetches the list of available Claude models from the Anthropic models endpoint.
* <p>
* The adapter:
* <ol>
* <li>Validates that the request carries a non-blank API key.</li>
* <li>Resolves the base URL (falls back to {@value #DEFAULT_BASE_URL}).</li>
* <li>Sends {@code GET {baseUrl}/v1/models} with Anthropic authentication headers.</li>
* <li>Maps HTTP 200 + non-empty {@code data} array to {@link ModelCatalogResult.Success}.</li>
* <li>Maps HTTP 200 + empty array to {@link ModelCatalogResult.EmptyList}.</li>
* <li>Maps HTTP 401 / 403 to {@link ModelCatalogResult.TechnicalFailure} with
* {@code AUTHENTICATION_FAILED}.</li>
* <li>Maps HTTP 404 to {@code ENDPOINT_NOT_FOUND}.</li>
* <li>Maps HTTP 5xx to {@code SERVER_ERROR}.</li>
* <li>Maps timeouts and connection failures to {@code CONNECTION_FAILURE}.</li>
* <li>Maps unparseable responses to {@code INVALID_RESPONSE}.</li>
* </ol>
*
* @param request all values needed to contact the provider; must not be {@code null}
* @return a non-{@code null} result encoding the outcome
* @throws NullPointerException if {@code request} is {@code null}
*/
@Override
public ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request) {
java.util.Objects.requireNonNull(request, "request must not be null");
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
LOG.warn("Claude model catalogue: API key is missing cannot fetch model list");
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
}
String apiKey = request.apiKey().get();
String baseUrl = request.baseUrl()
.filter(u -> !u.isBlank())
.orElse(DEFAULT_BASE_URL);
URI endpoint = buildEndpointUri(baseUrl);
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
LOG.info("Claude model catalogue: fetching models from {}", endpoint);
try {
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
.header(API_KEY_HEADER, apiKey)
.header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
.GET()
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
LOG.warn("Claude model catalogue: request timed out {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
LOG.warn("Claude model catalogue: connection failed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
LOG.warn("Claude model catalogue: hostname not resolvable {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
LOG.warn("Claude model catalogue: IO error {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("Claude model catalogue: request interrupted");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("Claude model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter Fehler: " + e.getMessage());
}
}
/**
* Maps the HTTP response to the appropriate {@link ModelCatalogResult} sub-type.
*
* @param response the HTTP response from the Anthropic models endpoint
* @return the mapped result; never {@code null}
*/
private ModelCatalogResult handleResponse(HttpResponse<String> response) {
int status = response.statusCode();
if (status == 401 || status == 403) {
LOG.warn("Claude model catalogue: authentication failed HTTP {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "AUTHENTICATION_FAILED",
"Authentifizierung fehlgeschlagen (HTTP " + status + ")");
}
if (status == 404) {
LOG.warn("Claude model catalogue: endpoint not found HTTP 404");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "ENDPOINT_NOT_FOUND",
"Endpunkt nicht gefunden (HTTP 404)");
}
if (status >= 500) {
LOG.warn("Claude model catalogue: server error HTTP {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "SERVER_ERROR",
"Serverfehler beim Modellabruf (HTTP " + status + ")");
}
if (status != 200) {
LOG.warn("Claude model catalogue: unexpected HTTP status {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter HTTP-Status: " + status);
}
return parseModelsResponse(response.body());
}
/**
* Parses the JSON response body from the Anthropic models endpoint.
* <p>
* Expects a top-level {@code data} array where each element has an {@code id} field.
*
* @param responseBody the raw JSON response body
* @return {@link ModelCatalogResult.Success} or {@link ModelCatalogResult.EmptyList}
* on parse success; {@link ModelCatalogResult.TechnicalFailure} with
* {@code INVALID_RESPONSE} on parse failure
*/
private ModelCatalogResult parseModelsResponse(String responseBody) {
try {
JSONObject json = new JSONObject(responseBody);
JSONArray dataArray = json.getJSONArray("data");
List<String> modelIds = new ArrayList<>();
for (int i = 0; i < dataArray.length(); i++) {
JSONObject entry = dataArray.getJSONObject(i);
String id = entry.optString("id", "").trim();
if (!id.isEmpty()) {
modelIds.add(id);
}
}
if (modelIds.isEmpty()) {
LOG.warn("Claude model catalogue: provider returned empty model list");
return new ModelCatalogResult.EmptyList(PROVIDER_ID, Instant.now());
}
LOG.info("Claude model catalogue: loaded {} model(s)", modelIds.size());
return new ModelCatalogResult.Success(PROVIDER_ID, modelIds, Instant.now());
} catch (JSONException e) {
LOG.warn("Claude model catalogue: response could not be parsed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "INVALID_RESPONSE",
"Antwort konnte nicht verarbeitet werden: " + e.getMessage());
}
}
/**
* Builds the full endpoint URI for the Anthropic models endpoint.
*
* @param baseUrl the resolved base URL (never blank)
* @return the complete endpoint URI
*/
private URI buildEndpointUri(String baseUrl) {
URI base = URI.create(baseUrl);
String path = base.getPath().replaceAll("/$", "") + MODELS_ENDPOINT;
return URI.create(base.getScheme() + "://"
+ base.getHost()
+ (base.getPort() > 0 ? ":" + base.getPort() : "")
+ path);
}
/**
* Package-private factory method for test injection of a custom {@link HttpClient}.
* <p>
* <strong>For testing only.</strong> Allows tests to provide a mock or stub client
* without network access while exercising the full request-building and response-mapping
* logic.
*
* @param request the model catalogue request
* @param httpClient the HTTP client to use instead of creating a new one
* @return the mapped result
*/
ModelCatalogResult fetchAvailableModelsWithClient(ModelCatalogRequest request, HttpClient httpClient) {
java.util.Objects.requireNonNull(request, "request must not be null");
java.util.Objects.requireNonNull(httpClient, "httpClient must not be null");
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
LOG.warn("Claude model catalogue: API key is missing cannot fetch model list");
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
}
String apiKey = request.apiKey().get();
String baseUrl = request.baseUrl()
.filter(u -> !u.isBlank())
.orElse(DEFAULT_BASE_URL);
URI endpoint = buildEndpointUri(baseUrl);
LOG.info("Claude model catalogue: fetching models from {} (test client)", endpoint);
try {
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
.header(API_KEY_HEADER, apiKey)
.header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
.GET()
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("Claude model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter Fehler: " + e.getMessage());
}
}
}
@@ -0,0 +1,309 @@
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
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.time.Instant;
import java.util.ArrayList;
import java.util.List;
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.port.out.modelcatalog.AiModelCatalogPort;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
/**
* Adapter implementing {@link AiModelCatalogPort} for the OpenAI-compatible provider family.
* <p>
* Fetches the list of available models from the OpenAI-compatible models endpoint
* ({@code GET {baseUrl}/v1/models}). Authentication uses the standard
* {@code Authorization: Bearer <apiKey>} header.
* <p>
* <strong>Default base URL:</strong> When the request carries no base URL,
* {@code https://api.openai.com} is used automatically.
* <p>
* <strong>Error handling:</strong> All expected error conditions (missing API key,
* HTTP errors, timeouts, parse failures) are returned as specific
* {@link ModelCatalogResult} sub-types. No exception is thrown to the caller.
* <p>
* <strong>Thread safety:</strong> This adapter is stateless. All configuration
* values are read from the {@link ModelCatalogRequest} at call time. Multiple
* threads may call {@link #fetchAvailableModels(ModelCatalogRequest)} concurrently
* without synchronisation.
* <p>
* <strong>Non-goals:</strong>
* <ul>
* <li>Retry logic — the caller is responsible for retry decisions.</li>
* <li>Caching — a fresh HTTP call is made on every invocation.</li>
* <li>Shared implementation with the Claude adapter.</li>
* </ul>
*/
public class OpenAiCompatibleModelCatalogAdapter implements AiModelCatalogPort {
private static final Logger LOG = LogManager.getLogger(OpenAiCompatibleModelCatalogAdapter.class);
/** OpenAI-compatible models list endpoint path. */
private static final String MODELS_ENDPOINT = "/v1/models";
/** Default base URL for the OpenAI API, applied when the request carries no base URL. */
static final String DEFAULT_BASE_URL = "https://api.openai.com";
/** Standard OAuth2 bearer authorization header name. */
private static final String AUTHORIZATION_HEADER = "Authorization";
/** Bearer token prefix for the Authorization header. */
private static final String BEARER_PREFIX = "Bearer ";
private static final String PROVIDER_ID = "openai-compatible";
/**
* Creates a new stateless OpenAI-compatible model catalogue adapter.
* <p>
* No configuration is held in the instance. All request parameters are
* supplied per call via {@link ModelCatalogRequest}.
*/
public OpenAiCompatibleModelCatalogAdapter() {
// stateless — no fields to initialise
}
/**
* Fetches the list of available models from the OpenAI-compatible models endpoint.
* <p>
* The adapter:
* <ol>
* <li>Validates that the request carries a non-blank API key.</li>
* <li>Resolves the base URL (falls back to {@value #DEFAULT_BASE_URL}).</li>
* <li>Sends {@code GET {baseUrl}/v1/models} with {@code Authorization: Bearer} header.</li>
* <li>Maps HTTP 200 + non-empty {@code data} array to {@link ModelCatalogResult.Success}.</li>
* <li>Maps HTTP 200 + empty array to {@link ModelCatalogResult.EmptyList}.</li>
* <li>Maps HTTP 401 / 403 to {@link ModelCatalogResult.TechnicalFailure} with
* {@code AUTHENTICATION_FAILED}.</li>
* <li>Maps HTTP 404 to {@code ENDPOINT_NOT_FOUND}.</li>
* <li>Maps HTTP 5xx to {@code SERVER_ERROR}.</li>
* <li>Maps timeouts and connection failures to {@code CONNECTION_FAILURE}.</li>
* <li>Maps unparseable responses to {@code INVALID_RESPONSE}.</li>
* </ol>
*
* @param request all values needed to contact the provider; must not be {@code null}
* @return a non-{@code null} result encoding the outcome
* @throws NullPointerException if {@code request} is {@code null}
*/
@Override
public ModelCatalogResult fetchAvailableModels(ModelCatalogRequest request) {
java.util.Objects.requireNonNull(request, "request must not be null");
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
LOG.warn("OpenAI-compatible model catalogue: API key is missing cannot fetch model list");
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
}
String apiKey = request.apiKey().get();
String baseUrl = request.baseUrl()
.filter(u -> !u.isBlank())
.orElse(DEFAULT_BASE_URL);
URI endpoint = buildEndpointUri(baseUrl);
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
LOG.info("OpenAI-compatible model catalogue: fetching models from {}", endpoint);
try {
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
.GET()
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
LOG.warn("OpenAI-compatible model catalogue: request timed out {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
LOG.warn("OpenAI-compatible model catalogue: connection failed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
LOG.warn("OpenAI-compatible model catalogue: hostname not resolvable {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
LOG.warn("OpenAI-compatible model catalogue: IO error {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOG.warn("OpenAI-compatible model catalogue: request interrupted");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter Fehler: " + e.getMessage());
}
}
/**
* Maps the HTTP response to the appropriate {@link ModelCatalogResult} sub-type.
*
* @param response the HTTP response from the OpenAI-compatible models endpoint
* @return the mapped result; never {@code null}
*/
private ModelCatalogResult handleResponse(HttpResponse<String> response) {
int status = response.statusCode();
if (status == 401 || status == 403) {
LOG.warn("OpenAI-compatible model catalogue: authentication failed HTTP {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "AUTHENTICATION_FAILED",
"Authentifizierung fehlgeschlagen (HTTP " + status + ")");
}
if (status == 404) {
LOG.warn("OpenAI-compatible model catalogue: endpoint not found HTTP 404");
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "ENDPOINT_NOT_FOUND",
"Endpunkt nicht gefunden (HTTP 404)");
}
if (status >= 500) {
LOG.warn("OpenAI-compatible model catalogue: server error HTTP {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "SERVER_ERROR",
"Serverfehler beim Modellabruf (HTTP " + status + ")");
}
if (status != 200) {
LOG.warn("OpenAI-compatible model catalogue: unexpected HTTP status {}", status);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter HTTP-Status: " + status);
}
return parseModelsResponse(response.body());
}
/**
* Parses the JSON response body from the OpenAI-compatible models endpoint.
* <p>
* Expects a top-level {@code data} array where each element has an {@code id} field.
*
* @param responseBody the raw JSON response body
* @return {@link ModelCatalogResult.Success} or {@link ModelCatalogResult.EmptyList}
* on parse success; {@link ModelCatalogResult.TechnicalFailure} with
* {@code INVALID_RESPONSE} on parse failure
*/
private ModelCatalogResult parseModelsResponse(String responseBody) {
try {
JSONObject json = new JSONObject(responseBody);
JSONArray dataArray = json.getJSONArray("data");
List<String> modelIds = new ArrayList<>();
for (int i = 0; i < dataArray.length(); i++) {
JSONObject entry = dataArray.getJSONObject(i);
String id = entry.optString("id", "").trim();
if (!id.isEmpty()) {
modelIds.add(id);
}
}
if (modelIds.isEmpty()) {
LOG.warn("OpenAI-compatible model catalogue: provider returned empty model list");
return new ModelCatalogResult.EmptyList(PROVIDER_ID, Instant.now());
}
LOG.info("OpenAI-compatible model catalogue: loaded {} model(s)", modelIds.size());
return new ModelCatalogResult.Success(PROVIDER_ID, modelIds, Instant.now());
} catch (JSONException e) {
LOG.warn("OpenAI-compatible model catalogue: response could not be parsed {}", e.getMessage());
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "INVALID_RESPONSE",
"Antwort konnte nicht verarbeitet werden: " + e.getMessage());
}
}
/**
* Builds the full endpoint URI for the OpenAI-compatible models endpoint.
*
* @param baseUrl the resolved base URL (never blank)
* @return the complete endpoint URI
*/
private URI buildEndpointUri(String baseUrl) {
URI base = URI.create(baseUrl);
String path = base.getPath().replaceAll("/$", "") + MODELS_ENDPOINT;
return URI.create(base.getScheme() + "://"
+ base.getHost()
+ (base.getPort() > 0 ? ":" + base.getPort() : "")
+ path);
}
/**
* Package-private factory method for test injection of a custom {@link HttpClient}.
* <p>
* <strong>For testing only.</strong> Allows tests to provide a mock or stub client
* without network access while exercising the full request-building and response-mapping
* logic.
*
* @param request the model catalogue request
* @param httpClient the HTTP client to use instead of creating a new one
* @return the mapped result
*/
ModelCatalogResult fetchAvailableModelsWithClient(ModelCatalogRequest request, HttpClient httpClient) {
java.util.Objects.requireNonNull(request, "request must not be null");
java.util.Objects.requireNonNull(httpClient, "httpClient must not be null");
if (request.apiKey().isEmpty() || request.apiKey().get().isBlank()) {
return new ModelCatalogResult.IncompleteConfiguration(PROVIDER_ID, "API-Schlüssel fehlt");
}
String apiKey = request.apiKey().get();
String baseUrl = request.baseUrl()
.filter(u -> !u.isBlank())
.orElse(DEFAULT_BASE_URL);
URI endpoint = buildEndpointUri(baseUrl);
LOG.info("OpenAI-compatible model catalogue: fetching models from {} (test client)", endpoint);
try {
HttpRequest httpRequest = HttpRequest.newBuilder(endpoint)
.header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
.GET()
.timeout(Duration.ofSeconds(request.timeoutSeconds()))
.build();
HttpResponse<String> response = httpClient.send(httpRequest,
HttpResponse.BodyHandlers.ofString());
return handleResponse(response);
} catch (java.net.http.HttpTimeoutException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Zeitüberschreitung beim Modellabruf: " + e.getMessage());
} catch (java.net.ConnectException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Verbindung zum Endpunkt fehlgeschlagen: " + e.getMessage());
} catch (java.net.UnknownHostException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Hostname nicht auflösbar: " + e.getMessage());
} catch (java.io.IOException e) {
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"E/A-Fehler beim Modellabruf: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "CONNECTION_FAILURE",
"Modellabruf wurde unterbrochen.");
} catch (Exception e) {
LOG.error("OpenAI-compatible model catalogue: unexpected error", e);
return new ModelCatalogResult.TechnicalFailure(PROVIDER_ID, "UNKNOWN",
"Unerwarteter Fehler: " + e.getMessage());
}
}
}
@@ -0,0 +1,13 @@
/**
* Adapter implementations for the {@code AiModelCatalogPort} outbound port.
* <p>
* This package contains one concrete adapter per supported AI provider family.
* Each adapter translates a {@code ModelCatalogRequest} into a provider-specific
* HTTP request, maps the response to a {@code ModelCatalogResult} sub-type, and
* never throws checked or runtime exceptions for expected error conditions.
* <p>
* Provider-specific HTTP details (endpoints, authentication schemes, response
* structures) are encapsulated entirely within the respective adapter class.
* The Application and GUI layers remain free of any provider knowledge.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
@@ -0,0 +1,109 @@
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
import java.util.Objects;
import java.util.function.Function;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import de.gecheckt.pdf.umbenenner.application.validation.editor.ApiKeyResolutionPort;
/**
* Implementierung des {@link ApiKeyResolutionPort}, die Umgebungsvariablen aus der
* Systemprozessumgebung liest und den effektiven API-Key-Herkunftsdeskriptor zurückgibt.
* <p>
* Die Vorrangregel lautet:
* <ol>
* <li>Providerspezifische Umgebungsvariable ({@code ANTHROPIC_API_KEY} für Claude,
* {@code OPENAI_COMPATIBLE_API_KEY} für OpenAI-kompatibel)</li>
* <li>Bei {@code openai-compatible}: Legacy-Variable {@code PDF_UMBENENNER_API_KEY}</li>
* <li>Property-Wert aus dem Editor-Feld ({@code propertyValue})</li>
* <li>{@code ABSENT}, wenn keine Quelle einen Wert liefert</li>
* </ol>
* <p>
* Verwendet dieselben ENV-Variablen-Namen wie der bestehende headless Konfigurationspfad, so dass
* GUI-Validierung und headless Bootstrap identisch auflösen.
* <p>
* Für Tests kann der Umgebungslookup injiziert werden, ohne den echten Prozess-Umgebungszustand
* zu verändern.
*/
public class EnvironmentApiKeyResolutionAdapter implements ApiKeyResolutionPort {
/** Providerspezifische Umgebungsvariable für den Claude-Provider. */
static final String ENV_CLAUDE_API_KEY = "ANTHROPIC_API_KEY";
/** Providerspezifische Umgebungsvariable für den OpenAI-kompatiblen Provider. */
static final String ENV_OPENAI_API_KEY = "OPENAI_COMPATIBLE_API_KEY";
/** Legacy-Umgebungsvariable für den OpenAI-kompatiblen Provider (Rückwärtskompatibilität). */
static final String ENV_LEGACY_OPENAI_API_KEY = "PDF_UMBENENNER_API_KEY";
private final Function<String, String> environmentLookup;
/**
* Erstellt einen Adapter, der die echte Prozessumgebung liest.
*/
public EnvironmentApiKeyResolutionAdapter() {
this(System::getenv);
}
/**
* Erstellt einen Adapter mit einem injizierten Umgebungslookup.
* <p>
* Dieser Konstruktor erlaubt deterministisches Testen ohne Änderungen am echten
* Prozess-Umgebungszustand.
*
* @param environmentLookup Funktion zum Lesen von Umgebungsvariablen; darf nicht {@code null} sein
*/
EnvironmentApiKeyResolutionAdapter(Function<String, String> environmentLookup) {
this.environmentLookup = Objects.requireNonNull(environmentLookup,
"environmentLookup must not be null");
}
/**
* Ermittelt die Herkunft des effektiven API-Schlüssels für den angegebenen Provider.
*
* @param family die Provider-Familie; darf nicht {@code null} sein
* @param propertyValue aktueller Property-Wert aus dem Editor; darf nicht {@code null} sein
* @return der Herkunftsdeskriptor; nie {@code null}
*/
@Override
public EffectiveApiKeyDescriptor resolve(AiProviderFamily family, String propertyValue) {
Objects.requireNonNull(family, "family must not be null");
Objects.requireNonNull(propertyValue, "propertyValue must not be null");
return switch (family) {
case CLAUDE -> resolveClaude(propertyValue);
case OPENAI_COMPATIBLE -> resolveOpenAiCompatible(propertyValue);
};
}
private EffectiveApiKeyDescriptor resolveClaude(String propertyValue) {
String envValue = environmentLookup.apply(ENV_CLAUDE_API_KEY);
if (isPresent(envValue)) {
return EffectiveApiKeyDescriptor.fromProviderEnvVar(ENV_CLAUDE_API_KEY);
}
if (isPresent(propertyValue)) {
return EffectiveApiKeyDescriptor.fromPropertyFile();
}
return EffectiveApiKeyDescriptor.absent();
}
private EffectiveApiKeyDescriptor resolveOpenAiCompatible(String propertyValue) {
String primaryEnv = environmentLookup.apply(ENV_OPENAI_API_KEY);
if (isPresent(primaryEnv)) {
return EffectiveApiKeyDescriptor.fromProviderEnvVar(ENV_OPENAI_API_KEY);
}
String legacyEnv = environmentLookup.apply(ENV_LEGACY_OPENAI_API_KEY);
if (isPresent(legacyEnv)) {
return EffectiveApiKeyDescriptor.fromLegacyEnvVar(ENV_LEGACY_OPENAI_API_KEY);
}
if (isPresent(propertyValue)) {
return EffectiveApiKeyDescriptor.fromPropertyFile();
}
return EffectiveApiKeyDescriptor.absent();
}
private static boolean isPresent(String value) {
return value != null && !value.isBlank();
}
}
@@ -0,0 +1,8 @@
/**
* Adapter-Out-Implementierungen für Validierungsinfrastruktur.
* <p>
* Dieses Package enthält konkrete Implementierungen der Outbound-Ports,
* die für die editornahe Konfigurationsvalidierung benötigt werden, insbesondere
* die Auflösung der API-Key-Herkunft aus Umgebungsvariablen.
*/
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
@@ -0,0 +1,258 @@
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
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.util.Optional;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
/**
* Unit tests for {@link ClaudeModelCatalogAdapter}.
* <p>
* All tests inject a mock {@link HttpClient} via the package-private
* {@code fetchAvailableModelsWithClient} method to avoid network access.
*/
@ExtendWith(MockitoExtension.class)
class ClaudeModelCatalogAdapterTest {
private static final String API_KEY = "test-api-key";
private static final String BASE_URL = "http://localhost:9999";
private static final int TIMEOUT = 5;
private static final String PROVIDER_ID = "claude";
@Mock
private HttpClient httpClient;
@Mock
@SuppressWarnings("unchecked")
private HttpResponse<String> httpResponse;
private ClaudeModelCatalogAdapter adapter;
@BeforeEach
void setUp() {
adapter = new ClaudeModelCatalogAdapter();
}
@Test
@DisplayName("HTTP 200 with non-empty model list returns Success")
void fetchModels_http200WithModels_returnsSuccess() throws Exception {
String responseBody = """
{"data":[{"id":"claude-3-opus","type":"model"},{"id":"claude-3-sonnet","type":"model"}]}
""";
doReturn(200).when(httpResponse).statusCode();
doReturn(responseBody).when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.Success.class);
ModelCatalogResult.Success success = (ModelCatalogResult.Success) result;
assertThat(success.models()).containsExactly("claude-3-opus", "claude-3-sonnet");
assertThat(success.providerIdentifier()).isEqualTo(PROVIDER_ID);
}
@Test
@DisplayName("HTTP 200 with empty data array returns EmptyList")
void fetchModels_http200EmptyDataArray_returnsEmptyList() throws Exception {
String responseBody = """
{"data":[]}
""";
doReturn(200).when(httpResponse).statusCode();
doReturn(responseBody).when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.EmptyList.class);
}
@Test
@DisplayName("HTTP 401 returns TechnicalFailure with AUTHENTICATION_FAILED")
void fetchModels_http401_returnsAuthenticationFailed() throws Exception {
doReturn(401).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
ModelCatalogResult.TechnicalFailure failure = (ModelCatalogResult.TechnicalFailure) result;
assertThat(failure.errorCategory()).isEqualTo("AUTHENTICATION_FAILED");
}
@Test
@DisplayName("HTTP 403 returns TechnicalFailure with AUTHENTICATION_FAILED")
void fetchModels_http403_returnsAuthenticationFailed() throws Exception {
doReturn(403).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("AUTHENTICATION_FAILED");
}
@Test
@DisplayName("HTTP 404 returns TechnicalFailure with ENDPOINT_NOT_FOUND")
void fetchModels_http404_returnsEndpointNotFound() throws Exception {
doReturn(404).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("ENDPOINT_NOT_FOUND");
}
@Test
@DisplayName("HTTP 500 returns TechnicalFailure with SERVER_ERROR")
void fetchModels_http500_returnsServerError() throws Exception {
doReturn(500).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("SERVER_ERROR");
}
@Test
@DisplayName("ConnectException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_connectException_returnsConnectionFailure() throws Exception {
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new ConnectException("connection refused"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
@Test
@DisplayName("Unparseable JSON response returns TechnicalFailure with INVALID_RESPONSE")
void fetchModels_invalidJson_returnsInvalidResponse() throws Exception {
doReturn(200).when(httpResponse).statusCode();
doReturn("this is not json at all!!!").when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("INVALID_RESPONSE");
}
@Test
@DisplayName("Missing API key returns IncompleteConfiguration")
void fetchModels_missingApiKey_returnsIncompleteConfiguration() {
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.empty(), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
ModelCatalogResult.IncompleteConfiguration incomplete =
(ModelCatalogResult.IncompleteConfiguration) result;
assertThat(incomplete.missingReason()).containsIgnoringCase("API-Schlüssel");
}
@Test
@DisplayName("Blank API key returns IncompleteConfiguration")
void fetchModels_blankApiKey_returnsIncompleteConfiguration() {
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(" "), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
}
@Test
@DisplayName("No base URL falls back to Anthropic default")
void fetchModels_noBaseUrl_usesDefault() {
// Verify that no exception is thrown when building the URI with no base URL.
// The adapter should fall back to https://api.anthropic.com without crashing.
// (We don't send a real HTTP request here; we just verify the adapter doesn't throw
// during the URI construction phase before the send call.)
assertThat(ClaudeModelCatalogAdapter.DEFAULT_BASE_URL).isEqualTo("https://api.anthropic.com");
}
@Test
@DisplayName("UnknownHostException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_unknownHost_returnsConnectionFailure() throws Exception {
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new UnknownHostException("unknown host"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
@Test
@DisplayName("HttpTimeoutException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_httpTimeout_returnsConnectionFailure() throws Exception {
// The adapter groups HTTP timeouts together with other connection failures under
// the CONNECTION_FAILURE category (no separate TIMEOUT category).
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new HttpTimeoutException("request timed out"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
}
@@ -0,0 +1,254 @@
package de.gecheckt.pdf.umbenenner.adapter.out.modelcatalog;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
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.util.Optional;
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.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
/**
* Unit tests for {@link OpenAiCompatibleModelCatalogAdapter}.
* <p>
* All tests inject a mock {@link HttpClient} via the package-private
* {@code fetchAvailableModelsWithClient} method to avoid network access.
*/
@ExtendWith(MockitoExtension.class)
class OpenAiCompatibleModelCatalogAdapterTest {
private static final String API_KEY = "test-openai-key";
private static final String BASE_URL = "http://localhost:8888";
private static final int TIMEOUT = 5;
private static final String PROVIDER_ID = "openai-compatible";
@Mock
private HttpClient httpClient;
@Mock
@SuppressWarnings("unchecked")
private HttpResponse<String> httpResponse;
private OpenAiCompatibleModelCatalogAdapter adapter;
@BeforeEach
void setUp() {
adapter = new OpenAiCompatibleModelCatalogAdapter();
}
@Test
@DisplayName("HTTP 200 with non-empty model list returns Success")
void fetchModels_http200WithModels_returnsSuccess() throws Exception {
String responseBody = """
{"object":"list","data":[{"id":"gpt-4o","object":"model"},{"id":"gpt-4","object":"model"}]}
""";
doReturn(200).when(httpResponse).statusCode();
doReturn(responseBody).when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.Success.class);
ModelCatalogResult.Success success = (ModelCatalogResult.Success) result;
assertThat(success.models()).containsExactly("gpt-4o", "gpt-4");
assertThat(success.providerIdentifier()).isEqualTo(PROVIDER_ID);
}
@Test
@DisplayName("HTTP 200 with empty data array returns EmptyList")
void fetchModels_http200EmptyDataArray_returnsEmptyList() throws Exception {
String responseBody = """
{"object":"list","data":[]}
""";
doReturn(200).when(httpResponse).statusCode();
doReturn(responseBody).when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.EmptyList.class);
}
@Test
@DisplayName("HTTP 401 returns TechnicalFailure with AUTHENTICATION_FAILED")
void fetchModels_http401_returnsAuthenticationFailed() throws Exception {
doReturn(401).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("AUTHENTICATION_FAILED");
}
@Test
@DisplayName("HTTP 403 returns TechnicalFailure with AUTHENTICATION_FAILED")
void fetchModels_http403_returnsAuthenticationFailed() throws Exception {
doReturn(403).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("AUTHENTICATION_FAILED");
}
@Test
@DisplayName("HTTP 404 returns TechnicalFailure with ENDPOINT_NOT_FOUND")
void fetchModels_http404_returnsEndpointNotFound() throws Exception {
doReturn(404).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("ENDPOINT_NOT_FOUND");
}
@Test
@DisplayName("HTTP 500 returns TechnicalFailure with SERVER_ERROR")
void fetchModels_http500_returnsServerError() throws Exception {
doReturn(500).when(httpResponse).statusCode();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("SERVER_ERROR");
}
@Test
@DisplayName("ConnectException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_connectException_returnsConnectionFailure() throws Exception {
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new ConnectException("connection refused"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
@Test
@DisplayName("Unparseable JSON response returns TechnicalFailure with INVALID_RESPONSE")
void fetchModels_invalidJson_returnsInvalidResponse() throws Exception {
doReturn(200).when(httpResponse).statusCode();
doReturn("not-a-json-body").when(httpResponse).body();
doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("INVALID_RESPONSE");
}
@Test
@DisplayName("Missing API key returns IncompleteConfiguration")
void fetchModels_missingApiKey_returnsIncompleteConfiguration() {
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.empty(), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
ModelCatalogResult.IncompleteConfiguration incomplete =
(ModelCatalogResult.IncompleteConfiguration) result;
assertThat(incomplete.missingReason()).containsIgnoringCase("API-Schlüssel");
}
@Test
@DisplayName("Blank API key returns IncompleteConfiguration")
void fetchModels_blankApiKey_returnsIncompleteConfiguration() {
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(" "), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
}
@Test
@DisplayName("No base URL falls back to OpenAI default")
void fetchModels_noBaseUrl_usesDefault() {
assertThat(OpenAiCompatibleModelCatalogAdapter.DEFAULT_BASE_URL)
.isEqualTo("https://api.openai.com");
}
@Test
@DisplayName("UnknownHostException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_unknownHost_returnsConnectionFailure() throws Exception {
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new UnknownHostException("unknown host"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
@Test
@DisplayName("HttpTimeoutException returns TechnicalFailure with CONNECTION_FAILURE")
void fetchModels_httpTimeout_returnsConnectionFailure() throws Exception {
// The adapter groups HTTP timeouts together with other connection failures under
// the CONNECTION_FAILURE category (no separate TIMEOUT category).
org.mockito.Mockito.when(httpClient.send(any(HttpRequest.class), any()))
.thenThrow(new HttpTimeoutException("request timed out"));
ModelCatalogRequest request = new ModelCatalogRequest(PROVIDER_ID,
Optional.of(BASE_URL), Optional.of(API_KEY), TIMEOUT);
ModelCatalogResult result = adapter.fetchAvailableModelsWithClient(request, httpClient);
assertThat(result).isInstanceOf(ModelCatalogResult.TechnicalFailure.class);
assertThat(((ModelCatalogResult.TechnicalFailure) result).errorCategory())
.isEqualTo("CONNECTION_FAILURE");
}
}
@@ -0,0 +1,207 @@
package de.gecheckt.pdf.umbenenner.adapter.out.validation;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ApiKeyOrigin;
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.EffectiveApiKeyDescriptor;
import org.junit.jupiter.api.Test;
/**
* Unit tests for {@link EnvironmentApiKeyResolutionAdapter}.
* <p>
* The environment lookup is injected via the package-private constructor so that tests
* are deterministic and do not depend on real process environment variables.
*/
class EnvironmentApiKeyResolutionAdapterTest {
// =========================================================================
// Helper
// =========================================================================
private static EnvironmentApiKeyResolutionAdapter adapterWith(Map<String, String> env) {
Function<String, String> lookup = env::get;
return new EnvironmentApiKeyResolutionAdapter(lookup);
}
// =========================================================================
// Claude provider env var takes precedence
// =========================================================================
@Test
void claude_envVarPresent_returnsFromProviderEnvVar() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, "sk-ant-test");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.CLAUDE, "");
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY,
result.envVarName().orElseThrow());
}
@Test
void claude_envVarPresentAndPropertyAlsoPresent_envVarWins() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, "sk-ant-env");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.CLAUDE, "sk-ant-property");
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
}
// =========================================================================
// Claude property file fallback
// =========================================================================
@Test
void claude_noEnvVar_propertyPresent_returnsFromPropertyFile() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.CLAUDE, "sk-ant-property");
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
}
// =========================================================================
// Claude absent
// =========================================================================
@Test
void claude_noEnvVar_noProperty_returnsAbsent() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.CLAUDE, "");
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
}
@Test
void claude_noEnvVar_blankPropertyOnly_returnsAbsent() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.CLAUDE, " ");
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
}
// =========================================================================
// OpenAI-compatible primary env var takes precedence
// =========================================================================
@Test
void openai_primaryEnvVarPresent_returnsFromProviderEnvVar() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "sk-openai-test");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY,
result.envVarName().orElseThrow());
}
@Test
void openai_primaryEnvVarAndLegacyBothPresent_primaryWins() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "sk-primary",
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
assertEquals(ApiKeyOrigin.FROM_PROVIDER_ENV_VAR, result.origin());
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY,
result.envVarName().orElseThrow());
}
// =========================================================================
// OpenAI-compatible legacy env var
// =========================================================================
@Test
void openai_noPrimaryEnvVar_legacyPresent_returnsFromLegacyEnvVar() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy-key");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
assertEquals(ApiKeyOrigin.FROM_LEGACY_ENV_VAR, result.origin());
assertEquals(EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY,
result.envVarName().orElseThrow());
}
@Test
void openai_noPrimaryEnvVar_legacyPresentAndPropertyAlsoPresent_legacyWins() {
Map<String, String> env = Map.of(
EnvironmentApiKeyResolutionAdapter.ENV_LEGACY_OPENAI_API_KEY, "sk-legacy");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-property");
assertEquals(ApiKeyOrigin.FROM_LEGACY_ENV_VAR, result.origin());
}
// =========================================================================
// OpenAI-compatible property file fallback
// =========================================================================
@Test
void openai_noEnvVars_propertyPresent_returnsFromPropertyFile() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-openai-property");
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
}
// =========================================================================
// OpenAI-compatible absent
// =========================================================================
@Test
void openai_noEnvVars_noProperty_returnsAbsent() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "");
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
}
@Test
void openai_noEnvVars_blankProperty_returnsAbsent() {
EffectiveApiKeyDescriptor result = adapterWith(Map.of())
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, " ");
assertEquals(ApiKeyOrigin.ABSENT, result.origin());
}
// =========================================================================
// Blank env value treated as absent
// =========================================================================
@Test
void claude_envVarPresentButBlank_treatedAsAbsent_fallsBackToProperty() {
Map<String, String> env = new HashMap<>();
env.put(EnvironmentApiKeyResolutionAdapter.ENV_CLAUDE_API_KEY, " ");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.CLAUDE, "sk-prop");
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
}
@Test
void openai_primaryEnvVarBlank_legacyAbsent_propertyPresent_returnsFromPropertyFile() {
Map<String, String> env = new HashMap<>();
env.put(EnvironmentApiKeyResolutionAdapter.ENV_OPENAI_API_KEY, "");
EffectiveApiKeyDescriptor result = adapterWith(env)
.resolve(AiProviderFamily.OPENAI_COMPATIBLE, "sk-prop");
assertEquals(ApiKeyOrigin.FROM_PROPERTY_FILE, result.origin());
}
}