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