using System.Net;
using System.Net.Sockets;
using Deal.Grpc.Ai;
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
namespace Deal.Ai.Tests.Grpc;
///
/// Интеграционные тесты хоста ai-service.
///
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";
private const string TenantId = "tenant-test";
// Deadline RPC-вызовов теста (сек).
private const int RpcDeadlineSeconds = 10;
///
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают.
///
[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);
});
}
///
/// Запрос без metadata «service-token» → UNAUTHENTICATED.
///
[Fact]
public async Task Classify_WithoutToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: null,
expected: StatusCode.Unauthenticated);
}
///
/// Запрос с неверным токеном → UNAUTHENTICATED.
///
[Fact]
public async Task Classify_WithWrongToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: "wrong-token",
expected: StatusCode.Unauthenticated);
}
///
/// Верный токен проходит интерцептор к методу — кодогенерация и маппинг сервиса работают
///
[Fact]
public async Task Classify_WithValidToken_ReachesServiceAndValidatesConfig()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: ValidToken,
expected: StatusCode.InvalidArgument);
}
///
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч.
///
[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));
}
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 call = client.ClassifyAsync(new ClassifyRequest(), callOptions);
RpcException exception = await Assert.ThrowsAsync(() => call.ResponseAsync);
Assert.Equal(expected, exception.StatusCode);
}
// Поднимает хост на эфемерном порту с заданным env-токеном, выполняет сценарий и гарантированно
// гасит хост/канал и восстанавливает исходный env.
// serviceToken: Значение env DEAL_SERVICE_TOKEN для сценария (null — убрать).
// scenario: Сценарий с каналом к поднятому хосту.
private static async Task RunHostScenarioAsync(string? serviceToken, Func 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;
}
}