Files
Deal/src/ai-service/Deal.Ai.Tests/AiServiceHostTests.cs
T
Rustam Khalimov 492950bdd0 Отформатировать списки параметров по код-стайлу
Больше двух параметров — каждый на отдельной строке (закрывающая
скобка в конце последнего); два и меньше — в одну строку. Правило
добавлено в docs/spec/Код-стайл-Дейл.md; применено к 628 сигнатурам
в 253 файлах.
2026-09-11 13:22:56 +03:00

214 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Deal.Grpc.Ai;
using Deal.Ai;
using Microsoft.AspNetCore.Builder;
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
using System.Net;
using System.Net.Sockets;
using Xunit;
namespace Deal.Ai.Tests;
/// <summary>
/// Интеграционные тесты хоста ai-service (каркас Task 4 + логика Task 8).
///
/// Хост поднимается В процессе теста (Kestrel HTTP/2, эфемерный порт) через AiServiceHost.Create —
/// ту же сборку хоста, что использует Program.cs, поэтому тесты покрывают реальную настройку
/// Kestrel/AddGrpc/health, а не её копию. Проверки: gRPC-health → SERVING; ServiceTokenInterceptor
/// (Ruling 1): запрос без токена и с неверным токеном → UNAUTHENTICATED; верный токен проходит к
/// методу (реализация Task 8: пустой запрос без конфига провайдера → INVALID_ARGUMENT); при
/// незаданном DEAL_SERVICE_TOKEN — fail-closed.
///
/// Токен интерцептор читает из конфигурации (env DEAL_SERVICE_TOKEN) — тесты выставляют env на время
/// сценария и восстанавливают исходное значение. Все тесты класса живут в одном процессе/классе,
/// чтобы env и свободные порты не конфликтовали (xunit исполняет методы класса последовательно).
/// </summary>
public sealed class AiServiceHostTests
{
// Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor).
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
// Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey).
private const string ServiceTokenMetadataKey = "service-token";
// Токен сценариев теста.
private const string ValidToken = "task4-test-token";
// Id тенанта запросов (доходит до метода при верном токене; Ruling 1).
private const string TenantId = "tenant-test";
// Deadline RPC-вызовов теста (сек).
private const int RpcDeadlineSeconds = 10;
/// <summary>
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура
/// и health-сервис работают (Ruling 12; health освобождён от service-token).
/// </summary>
[Fact]
public async Task HealthCheck_ReturnsServing()
{
await RunHostScenarioAsync(
ValidToken,
async channel =>
{
var health = new Health.HealthClient(channel);
HealthCheckResponse response = await health.CheckAsync(
new HealthCheckRequest(),
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, response.Status);
});
}
/// <summary>
/// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1).
/// </summary>
[Fact]
public async Task Classify_WithoutToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: null,
expected: StatusCode.Unauthenticated);
}
/// <summary>
/// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1).
/// </summary>
[Fact]
public async Task Classify_WithWrongToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: "wrong-token",
expected: StatusCode.Unauthenticated);
}
/// <summary>
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают:
/// пустой запрос (без конфига провайдера) доходит до реализации Classify (Task 8) и отклоняется
/// валидацией INVALID_ARGUMENT, а не UNIMPLEMENTED.
/// </summary>
[Fact]
public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: ValidToken,
expected: StatusCode.InvalidArgument);
}
/// <summary>
/// Fail-closed (шаблон Task 3): DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда,
/// в т.ч. запрос с «пустым» значением metadata (без гарда сравнение «» == «» пропустило бы его);
/// health при этом продолжает отвечать SERVING (инфраструктурный liveness не ломается).
/// </summary>
[Fact]
public async Task WithoutConfiguredToken_DealRpcFailsClosed_HealthStillServing()
{
await RunHostScenarioAsync(
serviceToken: null,
async channel =>
{
var health = new Health.HealthClient(channel);
HealthCheckResponse healthResponse = await health.CheckAsync(
new HealthCheckRequest(),
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, healthResponse.Status);
// Пустое значение metadata — попытка обойти fail-closed (равно незаданному env-токену).
await AssertRejectedAsync(channel, string.Empty, StatusCode.Unauthenticated);
await AssertRejectedAsync(channel, ValidToken, StatusCode.Unauthenticated);
});
}
// Прогоняет Classify с заданным заголовком «service-token» (null — без заголовка) и проверяет
// ожидаемый код статуса RPC-исключения.
// serviceToken: Ожидаемый токен хоста (env DEAL_SERVICE_TOKEN).
// tokenHeader: Значение metadata «service-token» запроса либо null (нет заголовка).
// expected: Ожидаемый StatusCode ответа.
private static async Task AssertDealRpcRejectedAsync(
string? serviceToken,
string? tokenHeader,
StatusCode expected)
{
await RunHostScenarioAsync(
serviceToken,
channel => AssertRejectedAsync(channel, tokenHeader, expected));
}
// Вызывает Classify и проверяет, что сервер ответил ожидаемым кодом статуса.
// Запрос несёт полный metadata (service-token + tenant-id), чтобы верный токен доходил
// до реализации метода (заглушки больше нет — Task 8), а не падал на tenant-проверке.
// channel: Канал к хосту ai-service.
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
// expected: Ожидаемый StatusCode.
private static async Task AssertRejectedAsync(
GrpcChannel channel,
string? tokenHeader,
StatusCode expected)
{
var client = new AiService.AiServiceClient(channel);
Metadata metadata = new() { { "tenant-id", TenantId } };
if (tokenHeader is not null)
{
metadata.Add(ServiceTokenMetadataKey, tokenHeader);
}
var callOptions = new CallOptions(
metadata,
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
AsyncUnaryCall<ClassifyReply> call = client.ClassifyAsync(new ClassifyRequest(), callOptions);
RpcException exception = await Assert.ThrowsAsync<RpcException>(() => call.ResponseAsync);
Assert.Equal(expected, exception.StatusCode);
}
// Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно
// гасит хост/канал и восстанавливает исходный env.
// serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать).
// scenario: Сценарий с каналом к поднятому хосту.
private static async Task RunHostScenarioAsync(string? serviceToken, Func<GrpcChannel, Task> scenario)
{
string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey);
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
WebApplication? app = null;
GrpcChannel? channel = null;
try
{
int port = FreeTcpPort();
app = AiServiceHost.Create(port);
await app.StartAsync();
channel = GrpcChannel.ForAddress($"http://127.0.0.1:{port}");
await scenario(channel);
}
finally
{
if (channel is not null)
{
channel.Dispose();
}
if (app is not null)
{
await app.StopAsync();
await app.DisposeAsync();
}
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken);
}
}
// Возвращает свободный TCP-порт (127.0.0.1:0 → освобождение перед биндом хоста).
private static int FreeTcpPort()
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
}