Files
Deal/src/ml-service/Deal.Ml.Tests/Grpc/MlServiceHostTests.cs
T
Rustam Khalimov 27c7831910
ci / build-test (push) Canceled after 0s
Deal — единая кодовая база
SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
2026-09-11 23:56:47 +03:00

231 lines
9.4 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 System.Net;
using System.Net.Sockets;
using Deal.Grpc.Ml;
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
namespace Deal.Ml.Tests.Grpc;
/// <summary>
/// Интеграционные тесты хоста ml-service.
/// </summary>
public sealed class MlServiceHostTests
{
// Env-ключ ожидаемого токена (зеркало ServiceTokenInterceptor).
private const string ServiceTokenEnvKey = "DEAL_SERVICE_TOKEN";
// Env-ключ каталога моделей (зеркало MlOptions; Status создаёт модель тенанта).
private const string DataDirEnvKey = "DEAL_ML_DATA_DIR";
// Id тенанта сценариев теста (Status реализован задачами 5–6).
private const string TestTenantId = "tenant-test";
// Ключ gRPC-metadata с tenant-id (зеркало MlServiceImpl.TenantIdMetadataKey).
private const string TenantIdMetadataKey = "tenant-id";
// Ключ gRPC-metadata (зеркало ServiceTokenInterceptor.ServiceTokenMetadataKey).
private const string ServiceTokenMetadataKey = "service-token";
// Токен сценариев теста.
private const string ValidToken = "task3-test-token";
// Deadline RPC-вызовов теста (сек).
private const int RpcDeadlineSeconds = 10;
/// <summary>
/// HealthCheck (grpc.health.v1.Health/Check) отвечает SERVING — хост поднялся, gRPC-инфраструктура и health-сервис работают.
/// </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.
/// </summary>
[Fact]
public async Task Status_WithoutToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: null,
expected: StatusCode.Unauthenticated);
}
/// <summary>
/// Запрос с неверным токеном → UNAUTHENTICATED.
/// </summary>
[Fact]
public async Task Status_WithWrongToken_IsUnauthenticated()
{
await AssertDealRpcRejectedAsync(
ValidToken,
tokenHeader: "wrong-token",
expected: StatusCode.Unauthenticated);
}
/// <summary>
/// Верный токен проходит интерцептор к методу Status
/// </summary>
[Fact]
public async Task Status_WithValidToken_ReturnsEmptyStatus()
{
await RunHostScenarioAsync(
ValidToken,
async channel =>
{
var client = new MlService.MlServiceClient(channel);
Metadata metadata = new();
metadata.Add(ServiceTokenMetadataKey, ValidToken);
metadata.Add(TenantIdMetadataKey, TestTenantId);
StatusReply response = await client.StatusAsync(
new StatusRequest(),
new CallOptions(metadata, deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds)));
Assert.False(response.Ready);
Assert.Empty(response.Classes);
Assert.Equal(0, response.Learned);
});
}
/// <summary>
/// Fail-closed: DEAL_SERVICE_TOKEN не задан — Deal-RPC отклоняется всегда, в т.ч.
/// </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);
});
}
// Прогоняет Status с заданным заголовком «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));
}
// Вызывает Status и проверяет, что сервер ответил ожидаемым кодом статуса.
// channel: Канал к хосту ml-service.
// tokenHeader: Значение metadata «service-token» либо null (нет заголовка).
// expected: Ожидаемый StatusCode.
private static async Task AssertRejectedAsync(
GrpcChannel channel,
string? tokenHeader,
StatusCode expected)
{
var client = new MlService.MlServiceClient(channel);
Metadata metadata = new();
if (tokenHeader is not null)
{
metadata.Add(ServiceTokenMetadataKey, tokenHeader);
}
var callOptions = new CallOptions(
metadata,
deadline: DateTime.UtcNow.AddSeconds(RpcDeadlineSeconds));
AsyncUnaryCall<StatusReply> call = client.StatusAsync(new StatusRequest(), 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);
string? originalDataDir = Environment.GetEnvironmentVariable(DataDirEnvKey);
// Status реализован (задачи 5–6): модель тенанта создаёт SQLite-файл — каталог теста
// направляем во временную папку, чтобы файлы не попали в data/ml репозитория.
string dataDir = Path.Combine(Path.GetTempPath(), "deal-ml-host-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dataDir);
Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken);
Environment.SetEnvironmentVariable(DataDirEnvKey, dataDir);
WebApplication? app = null;
GrpcChannel? channel = null;
try
{
int port = FreeTcpPort();
app = MlServiceHost.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);
Environment.SetEnvironmentVariable(DataDirEnvKey, originalDataDir);
try
{
Directory.Delete(dataDir, recursive: true);
}
catch (IOException)
{
// Каталог мог быть занят на момент удаления — тестовый мусор в temp допустим.
}
}
}
// Возвращает свободный 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;
}
}