using System.Net; using System.Text.Json.Nodes; using Deal.Ai.Llm; namespace Deal.Ai.Tests.Support; /// /// HTTP-тесты LlmHttpClient на заглушке HttpMessageHandler (план Task 7 Acceptance; без сети): /// форма OpenAI-совместимого запроса ({base}/chat/completions, Bearer, temperature 0.2, /// max_tokens 8000), форма Anthropic ({base}/v1/messages, x-api-key + anthropic-version), /// разбор ответов/usage, отказ без ключа, reasoning-без-ответа, HTTP-ошибка и таймаут. /// public sealed class LlmHttpClientTests { // Таймауты тестового клиента (короткие — чтобы таймаут-сценарий не ждал 90/60 с). private static readonly TimeSpan TestCallTimeout = TimeSpan.FromMilliseconds(60); // Задержка заглушки для таймаут-сценария (должна превышать TestCallTimeout). private static readonly TimeSpan TestHandlerDelay = TimeSpan.FromMilliseconds(300); // База OpenAI-совместимого провайдера сценария (как у openai/ollama/lmstudio — с /v1). private const string OpenAiBaseUrl = "https://api.example.com/v1"; // База Anthropic-провайдера сценария (без /v1 — путь добавляет клиент). private const string AnthropicBaseUrl = "https://api.anthropic.com"; // Ответ OpenAI-совместимого API с текстом и usage. private const string OpenAiJsonReply = """ { "choices": [ { "message": { "role": "assistant", "content": "{\"fit\": 1}" } } ], "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 } } """; // Ответ Anthropic Messages API: два text-блока и usage input/output. private const string AnthropicJsonReply = """ { "content": [ { "type": "text", "text": "{\"fit\": 1}" }, { "type": "text", "text": " ещё текст" } ], "usage": { "input_tokens": 4, "output_tokens": 6 } } """; /// /// OpenAI-совместимый вызов: URL, Bearer, форма тела (model/messages/temperature/max_tokens). /// [Fact] public async Task ChatAsync_OpenAiStyle_BuildsWireRequest() { var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply)); LlmHttpClient client = CreateClient(handler); ProviderChatResult result = await client.ChatAsync( OpenAiConfig(apiKey: "secret-key"), "Система", "Сообщение", CancellationToken.None); CapturedHttpRequest request = handler.LastRequest; Assert.StartsWith("POST https://api.example.com/v1/chat/completions", request.Url); Assert.Equal("Bearer secret-key", StubHttpMessageHandler.BearerOf(request)); JsonObject body = StubHttpMessageHandler.BodyOf(request); Assert.Equal("deepseek-v4-flash", (string?)body["model"]); Assert.Equal(0.2, (double?)body["temperature"]); Assert.Equal(8000, (int?)body["max_tokens"]); JsonArray messages = (JsonArray)body["messages"]!; Assert.Equal("system", (string?)messages[0]!["role"]); Assert.Equal("Система", (string?)messages[0]!["content"]); Assert.Equal("user", (string?)messages[1]!["role"]); Assert.Equal("Сообщение", (string?)messages[1]!["content"]); Assert.Equal("{\"fit\": 1}", result.Text); Assert.Equal(new ProviderUsage(10, 20, 30), result.Usage); } /// /// Локальный OpenAI-совместимый провайдер без ключа: заголовок Authorization не шлётся. /// [Fact] public async Task ChatAsync_OpenAiStyleWithoutApiKey_SkipsAuthorization() { var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(OpenAiJsonReply)); LlmHttpClient client = CreateClient(handler); await client.ChatAsync(OpenAiConfig(apiKey: null), "Система", "Сообщение", CancellationToken.None); Assert.Null(StubHttpMessageHandler.BearerOf(handler.LastRequest)); } /// /// Модель вернула только reasoning без ответа — сбой попытки (ai.py L149–151). /// [Fact] public async Task ChatAsync_OpenAiReasoningOnly_Throws() { const string reasoningOnlyReply = """{ "choices": [ { "message": { "role": "assistant", "reasoning_content": "хм, подумаю" } } ] }"""; var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(reasoningOnlyReply)); LlmHttpClient client = CreateClient(handler); LlmHttpException exception = await Assert.ThrowsAsync(() => client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); Assert.Contains("reasoning", exception.Message, StringComparison.OrdinalIgnoreCase); } /// /// Anthropic-вызов: URL /v1/messages, x-api-key + anthropic-version, форма тела Messages API. /// [Fact] public async Task ChatAsync_AnthropicStyle_BuildsWireRequest() { var handler = new StubHttpMessageHandler(StubHttpMessageHandler.JsonOk(AnthropicJsonReply)); LlmHttpClient client = CreateClient(handler); ProviderChatResult result = await client.ChatAsync( AnthropicConfig(), "Система", "Сообщение", CancellationToken.None); CapturedHttpRequest request = handler.LastRequest; Assert.StartsWith("POST https://api.anthropic.com/v1/messages", request.Url); Assert.Equal("anthropic-key", request.Headers["x-api-key"]); Assert.Equal("2023-06-01", request.Headers["anthropic-version"]); JsonObject body = StubHttpMessageHandler.BodyOf(request); Assert.Equal("claude-sonnet-5", (string?)body["model"]); Assert.Equal(8000, (int?)body["max_tokens"]); Assert.Equal("Система", (string?)body["system"]); Assert.DoesNotContain("temperature", body); JsonArray messages = (JsonArray)body["messages"]!; Assert.Single(messages); Assert.Equal("user", (string?)messages[0]!["role"]); Assert.Equal("Сообщение", (string?)messages[0]!["content"]); // Склейка text-блоков content[] + usage (input/output → total = сумма; ai.py L168–172). Assert.Equal("{\"fit\": 1} ещё текст", result.Text); Assert.Equal(new ProviderUsage(4, 6, 10), result.Usage); } /// /// HTTP-ошибка провайдера — сбой попытки с кодом статуса (повод для ретрая). /// [Fact] public async Task ChatAsync_HttpError_Throws() { var handler = new StubHttpMessageHandler(StubHttpMessageHandler.Status(HttpStatusCode.InternalServerError)); LlmHttpClient client = CreateClient(handler); LlmHttpException exception = await Assert.ThrowsAsync(() => client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); Assert.Contains("500", exception.Message, StringComparison.Ordinal); } /// /// Неожиданная форма ответа (не JSON) — сбой попытки, а не падение. /// [Fact] public async Task ChatAsync_UnexpectedBody_Throws() { var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("upstream error", System.Text.Encoding.UTF8, "text/html"), }); LlmHttpClient client = CreateClient(handler); await Assert.ThrowsAsync(() => client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); } /// /// Таймаут попытки (90/60 с в проде; в тесте — 60 мс) → LlmHttpException. /// [Fact] public async Task ChatAsync_Timeout_Throws() { var handler = new StubHttpMessageHandler( StubHttpMessageHandler.JsonOk(OpenAiJsonReply), delay: TestHandlerDelay); LlmHttpClient client = new( TestHttpClient(handler), TestCallTimeout, TestCallTimeout); LlmHttpException exception = await Assert.ThrowsAsync(() => client.ChatAsync(OpenAiConfig(), "Система", "Сообщение", CancellationToken.None)); Assert.Contains("Таймаут", exception.Message, StringComparison.Ordinal); } // Конфиг OpenAI-совместимого вызова сценария. // apiKey: Ключ API (null — локальный провайдер без ключа). private static LlmConfig OpenAiConfig(string? apiKey = "test-key") => new("deepseek", OpenAiBaseUrl, "deepseek-v4-flash", apiKey, ApiStyle: null); // Конфиг Anthropic-вызова сценария. private static LlmConfig AnthropicConfig() => new("anthropic", AnthropicBaseUrl, "claude-sonnet-5", "anthropic-key", LlmConfig.AnthropicApiStyle); // Клиент сценария над заглушкой (без общего таймаута — управление на попытку). // handler: Заглушка обработчика. private static LlmHttpClient CreateClient(StubHttpMessageHandler handler) => new(TestHttpClient(handler)); // HttpClient над заглушкой без общего таймаута (таймаут попытки задаёт клиент). // handler: Заглушка обработчика. private static HttpClient TestHttpClient(StubHttpMessageHandler handler) => new(handler) { Timeout = Timeout.InfiniteTimeSpan }; }