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:
+145
@@ -0,0 +1,145 @@
|
||||
package de.gecheckt.pdf.umbenenner.bootstrap.adapter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
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.AiModelCatalogPort;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogRequest;
|
||||
import de.gecheckt.pdf.umbenenner.application.port.out.modelcatalog.ModelCatalogResult;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link AiModelCatalogDispatcher}.
|
||||
* <p>
|
||||
* Verifies that the dispatcher correctly routes requests to the matching concrete
|
||||
* adapter based on the provider identifier in the request, and that unknown providers
|
||||
* are handled as {@link ModelCatalogResult.IncompleteConfiguration} without calling
|
||||
* either adapter.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AiModelCatalogDispatcherTest {
|
||||
|
||||
@Mock
|
||||
private AiModelCatalogPort claudeAdapter;
|
||||
|
||||
@Mock
|
||||
private AiModelCatalogPort openAiCompatibleAdapter;
|
||||
|
||||
private AiModelCatalogDispatcher dispatcher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
dispatcher = new AiModelCatalogDispatcher(claudeAdapter, openAiCompatibleAdapter);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Request with provider 'claude' is routed to the Claude adapter")
|
||||
void dispatch_claudeProvider_routesToClaudeAdapter() {
|
||||
ModelCatalogRequest request = new ModelCatalogRequest(
|
||||
"claude", Optional.empty(), Optional.of("key"), 5);
|
||||
ModelCatalogResult expected = new ModelCatalogResult.Success(
|
||||
"claude", List.of("claude-3-opus"), Instant.now());
|
||||
when(claudeAdapter.fetchAvailableModels(request)).thenReturn(expected);
|
||||
|
||||
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
|
||||
|
||||
assertThat(result).isSameAs(expected);
|
||||
verify(claudeAdapter).fetchAvailableModels(request);
|
||||
verifyNoInteractions(openAiCompatibleAdapter);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Request with provider 'openai-compatible' is routed to the OpenAI-compatible adapter")
|
||||
void dispatch_openAiCompatibleProvider_routesToOpenAiAdapter() {
|
||||
ModelCatalogRequest request = new ModelCatalogRequest(
|
||||
"openai-compatible", Optional.empty(), Optional.of("key"), 5);
|
||||
ModelCatalogResult expected = new ModelCatalogResult.Success(
|
||||
"openai-compatible", List.of("gpt-4o"), Instant.now());
|
||||
when(openAiCompatibleAdapter.fetchAvailableModels(request)).thenReturn(expected);
|
||||
|
||||
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
|
||||
|
||||
assertThat(result).isSameAs(expected);
|
||||
verify(openAiCompatibleAdapter).fetchAvailableModels(request);
|
||||
verifyNoInteractions(claudeAdapter);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unknown provider identifier returns IncompleteConfiguration without calling any adapter")
|
||||
void dispatch_unknownProvider_returnsIncompleteConfigurationWithoutCallingAdapters() {
|
||||
ModelCatalogRequest request = new ModelCatalogRequest(
|
||||
"unknown-provider", Optional.empty(), Optional.of("key"), 5);
|
||||
|
||||
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
|
||||
|
||||
assertThat(result).isInstanceOf(ModelCatalogResult.IncompleteConfiguration.class);
|
||||
ModelCatalogResult.IncompleteConfiguration incomplete =
|
||||
(ModelCatalogResult.IncompleteConfiguration) result;
|
||||
assertThat(incomplete.providerIdentifier()).isEqualTo("unknown-provider");
|
||||
assertThat(incomplete.missingReason()).containsIgnoringCase("Unbekannter Provider");
|
||||
verifyNoInteractions(claudeAdapter);
|
||||
verifyNoInteractions(openAiCompatibleAdapter);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null request throws NullPointerException")
|
||||
void dispatch_nullRequest_throwsNullPointerException() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> dispatcher.fetchAvailableModels(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null Claude adapter throws NullPointerException at construction")
|
||||
void constructor_nullClaudeAdapter_throwsNullPointerException() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new AiModelCatalogDispatcher(null, openAiCompatibleAdapter));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null OpenAI-compatible adapter throws NullPointerException at construction")
|
||||
void constructor_nullOpenAiAdapter_throwsNullPointerException() {
|
||||
assertThatNullPointerException()
|
||||
.isThrownBy(() -> new AiModelCatalogDispatcher(claudeAdapter, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Dispatcher propagates adapter result unchanged for claude")
|
||||
void dispatch_claudeAdapter_propagatesResultUnchanged() {
|
||||
ModelCatalogRequest request = new ModelCatalogRequest(
|
||||
"claude", Optional.empty(), Optional.of("k"), 10);
|
||||
ModelCatalogResult.TechnicalFailure failure = new ModelCatalogResult.TechnicalFailure(
|
||||
"claude", "AUTHENTICATION_FAILED", "Bad credentials");
|
||||
when(claudeAdapter.fetchAvailableModels(request)).thenReturn(failure);
|
||||
|
||||
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
|
||||
|
||||
assertThat(result).isSameAs(failure);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Dispatcher propagates adapter result unchanged for openai-compatible")
|
||||
void dispatch_openAiCompatibleAdapter_propagatesResultUnchanged() {
|
||||
ModelCatalogRequest request = new ModelCatalogRequest(
|
||||
"openai-compatible", Optional.empty(), Optional.of("k"), 10);
|
||||
ModelCatalogResult.EmptyList emptyList = new ModelCatalogResult.EmptyList(
|
||||
"openai-compatible", Instant.now());
|
||||
when(openAiCompatibleAdapter.fetchAvailableModels(request)).thenReturn(emptyList);
|
||||
|
||||
ModelCatalogResult result = dispatcher.fetchAvailableModels(request);
|
||||
|
||||
assertThat(result).isSameAs(emptyList);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user