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:
+315
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+309
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -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;
|
||||
+109
@@ -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();
|
||||
}
|
||||
}
|
||||
+8
@@ -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;
|
||||
+258
@@ -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");
|
||||
}
|
||||
}
|
||||
+254
@@ -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");
|
||||
}
|
||||
}
|
||||
+207
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user