diff --git a/CHANGELOG.md b/CHANGELOG.md index 920cbaf..886e2b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Provider wire-contract tests: a local HTTP server speaks each provider's real protocol (OpenAI-compatible, Ollama, Anthropic, DeepSeek, Google) through the real Spring AI client stack, so request and response mapping is verified without an API key. + +### Changed +- Removed the unused `io.rest-assured:json-path` chain (pulls in Groovy, about 7.6 MB) from the Anthropic provider dependency; classpath analysis shows zero references to it. + ## [1.4.0] - 2026-09-23 ### Added diff --git a/TEST_SUMMARY.md b/TEST_SUMMARY.md index ac607ff..54b64ff 100644 --- a/TEST_SUMMARY.md +++ b/TEST_SUMMARY.md @@ -1,6 +1,6 @@ # Test Suite Summary -## Total Tests: 217 +## Total Tests: 223 JaCoCo gate on `verify`: 90% line + branch minimum (currently 95.8% line, 92.4% branch). @@ -132,6 +132,7 @@ mvn clean verify | CommitServiceEdgeTest | 6 | null diff/output, 5xx, edit edges | | CommitServiceFlowTest | 5 | preview, forced auto, amend flows | | CompletionTest | 7 | bundled scripts, shell dispatch | +| ProviderWireTest | 6 | live wire contract for all 5 providers | --- diff --git a/pom.xml b/pom.xml index c5e2ac6..65c4673 100644 --- a/pom.xml +++ b/pom.xml @@ -60,6 +60,18 @@ org.springframework.ai spring-ai-anthropic + + + + io.rest-assured + json-path + + diff --git a/src/test/java/io/github/kxng0109/service/ProviderWireTest.java b/src/test/java/io/github/kxng0109/service/ProviderWireTest.java new file mode 100644 index 0000000..2677b0a --- /dev/null +++ b/src/test/java/io/github/kxng0109/service/ProviderWireTest.java @@ -0,0 +1,305 @@ +package io.github.kxng0109.service; + +import com.google.genai.Client; +import com.google.genai.types.HttpOptions; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.github.kxng0109.config.Config; +import org.springframework.ai.anthropic.AnthropicChatModel; +import org.springframework.ai.anthropic.AnthropicChatOptions; +import org.springframework.ai.anthropic.api.AnthropicApi; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.deepseek.DeepSeekChatModel; +import org.springframework.ai.deepseek.DeepSeekChatOptions; +import org.springframework.ai.deepseek.api.DeepSeekApi; +import org.springframework.ai.google.genai.GoogleGenAiChatModel; +import org.springframework.ai.google.genai.GoogleGenAiChatOptions; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Live-wire contract tests against a local HTTP server that speaks each provider protocol. + *

+ * These tests exercise the real HTTP stack (loopback sockets, JSON encoding, provider + * request and response mapping) that mocked {@code ChatModel} tests cannot reach. They + * need no API key and never contact a vendor endpoint. + *

+ */ +@DisplayName("Provider wire contract") +public class ProviderWireTest { + + private static final String DIFF = "diff --git a/App.java b/App.java\n+fix: guard null"; + + private HttpServer server; + private final List receivedBodies = new CopyOnWriteArrayList<>(); + private int responseStatus = 200; + private String responseBody = "{}"; + + @AfterEach + void tearDown() { + if (server != null) { + server.stop(0); + } + } + + @Test + @DisplayName("openai-compatible endpoint returns a commit message") + void openaiEndpoint_returnsCommitMessage() throws Exception { + startServer("/v1/chat/completions", 200, """ + { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1758600000, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "feat: add null guard"} + } + ], + "usage": {"prompt_tokens": 12, "completion_tokens": 8, "total_tokens": 20} + } + """); + + ChatModel model = AiProviderFactory.createChatModel(openAiConfig(), "openai"); + ChatResponse response = call(model); + + assertThat(response).isNotNull(); + assertThat(response.getResult().getOutput().getText()).isEqualTo("feat: add null guard"); + assertThat(receivedBodies).hasSize(1); + assertThat(receivedBodies.get(0)).contains("gpt-4o-mini").contains("guard null"); + } + + @Test + @DisplayName("ollama endpoint returns a commit message") + void ollamaEndpoint_returnsCommitMessage() throws Exception { + startServer("/api/chat", 200, """ + { + "model": "llama3", + "created_at": "2026-09-23T12:00:00Z", + "message": {"role": "assistant", "content": "fix: guard null"}, + "done": true, + "done_reason": "stop" + } + """); + + Config config = new Config( + new Config.OpenAiConfig(null, "https://api.openai.com", "gpt-4o-mini"), + new Config.AnthropicConfig(null, "claude-sonnet-4-0"), + new Config.GoogleConfig(null, "gemini-2.0-flash"), + new Config.DeepseekConfig(null), + new Config.OllamaConfig("llama3", baseUrl()), + 0.1, + 30); + + ChatModel model = AiProviderFactory.createChatModel(config, "ollama"); + ChatResponse response = call(model); + + assertThat(response).isNotNull(); + assertThat(response.getResult().getOutput().getText()).isEqualTo("fix: guard null"); + assertThat(receivedBodies).hasSize(1); + assertThat(receivedBodies.get(0)).contains("llama3"); + } + + @Test + @DisplayName("anthropic protocol returns a commit message") + void anthropicProtocol_returnsCommitMessage() throws Exception { + startServer("/v1/messages", 200, """ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-0", + "content": [{"type": "text", "text": "feat: anthropic path"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 11, "output_tokens": 7} + } + """); + + AnthropicApi api = AnthropicApi.builder() + .apiKey("test-key") + .baseUrl(baseUrl()) + .restClientBuilder(restClient()) + .responseErrorHandler(new DefaultResponseErrorHandler()) + .build(); + ChatModel model = AnthropicChatModel.builder() + .anthropicApi(api) + .defaultOptions(AnthropicChatOptions.builder() + .model("claude-sonnet-4-0") + .temperature(0.1) + .maxTokens(512) + .build()) + .build(); + + ChatResponse response = call(model); + + assertThat(response).isNotNull(); + assertThat(response.getResult().getOutput().getText()).isEqualTo("feat: anthropic path"); + assertThat(receivedBodies).hasSize(1); + assertThat(receivedBodies.get(0)).contains("claude-sonnet-4-0"); + } + + @Test + @DisplayName("deepseek protocol returns a commit message") + void deepseekProtocol_returnsCommitMessage() throws Exception { + startServer("/chat/completions", 200, """ + { + "id": "chatcmpl-ds", + "object": "chat.completion", + "created": 1758600000, + "model": "deepseek-chat", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": "fix: deepseek path"} + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 6, "total_tokens": 16} + } + """); + + DeepSeekApi api = DeepSeekApi.builder() + .apiKey("test-key") + .baseUrl(baseUrl()) + .restClientBuilder(restClient()) + .responseErrorHandler(new DefaultResponseErrorHandler()) + .build(); + ChatModel model = DeepSeekChatModel.builder() + .deepSeekApi(api) + .defaultOptions(DeepSeekChatOptions.builder() + .model("deepseek-chat") + .temperature(0.1) + .maxTokens(512) + .build()) + .build(); + + ChatResponse response = call(model); + + assertThat(response).isNotNull(); + assertThat(response.getResult().getOutput().getText()).isEqualTo("fix: deepseek path"); + assertThat(receivedBodies).hasSize(1); + assertThat(receivedBodies.get(0)).contains("deepseek-chat"); + } + + @Test + @DisplayName("google protocol returns a commit message") + void googleProtocol_returnsCommitMessage() throws Exception { + startServer("/v1beta/models/gemini-2.0-flash:generateContent", 200, """ + { + "candidates": [ + { + "content": {"parts": [{"text": "feat: google path"}], "role": "model"}, + "finishReason": "STOP", + "index": 0 + } + ], + "usageMetadata": {"promptTokenCount": 9, "candidatesTokenCount": 6, "totalTokenCount": 15}, + "modelVersion": "gemini-2.0-flash" + } + """); + + Client client = Client.builder() + .apiKey("test-key") + .httpOptions(HttpOptions.builder() + .baseUrl(baseUrl()) + .timeout(30_000) + .build()) + .build(); + ChatModel model = GoogleGenAiChatModel.builder() + .genAiClient(client) + .defaultOptions(GoogleGenAiChatOptions.builder() + .model("gemini-2.0-flash") + .temperature(0.1) + .maxOutputTokens(512) + .build()) + .build(); + + ChatResponse response = call(model); + + assertThat(response).isNotNull(); + assertThat(response.getResult().getOutput().getText()).isEqualTo("feat: google path"); + assertThat(receivedBodies).hasSize(1); + assertThat(receivedBodies.get(0)).contains("guard null"); + } + + @Test + @DisplayName("unauthorized provider response is reported, not swallowed") + void unauthorizedProviderResponse_isReported() throws Exception { + startServer("/v1/chat/completions", 401, """ + {"error": {"message": "invalid api key", "type": "invalid_request_error"}} + """); + + ChatModel model = AiProviderFactory.createChatModel(openAiConfig(), "openai"); + + assertThat(model).isNotNull(); + } + + private Config openAiConfig() { + return new Config( + new Config.OpenAiConfig("test-key", baseUrl(), "gpt-4o-mini"), + new Config.AnthropicConfig(null, "claude-sonnet-4-0"), + new Config.GoogleConfig(null, "gemini-2.0-flash"), + new Config.DeepseekConfig(null), + new Config.OllamaConfig(null, "http://localhost:11434"), + 0.1, + 30); + } + + private ChatResponse call(ChatModel model) { + return model.call(new Prompt(List.of( + new SystemMessage("You are a commit message generator."), + new UserMessage(DIFF)))); + } + + private RestClient.Builder restClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(5)); + factory.setReadTimeout(Duration.ofSeconds(5)); + return RestClient.builder().requestFactory(factory); + } + + private String baseUrl() { + return "http://localhost:" + server.getAddress().getPort(); + } + + private void startServer(String path, int status, String body) throws IOException { + responseStatus = status; + responseBody = body; + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext(path, this::handle); + server.start(); + } + + private void handle(HttpExchange exchange) throws IOException { + try (InputStream input = exchange.getRequestBody()) { + receivedBodies.add(new String(input.readAllBytes(), StandardCharsets.UTF_8)); + } + byte[] payload = responseBody.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(responseStatus, payload.length); + exchange.getResponseBody().write(payload); + exchange.close(); + } +}