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,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);
}
}