using Deal.Contracts.Integrations.Models; using Deal.Grpc.Ml; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations.Abstractions; using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Grpc; using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Support; using Microsoft.Extensions.Logging.Abstractions; using Deal.Contracts.Integrations.Abstractions; using Deal.SharedKernel.Tenants.Abstractions; namespace Deal.Tests.Unit.Contracts; /// /// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service. /// [Collection("MlGrpcTests")] public sealed class GrpcMlClientTests { // Идентификатор тенанта сценариев (формат N — ключ пула модели ml-service). private static readonly Guid Tenant = Guid.NewGuid(); // Id тенанта строкой (формат N) — ожидаемое значение metadata tenant-id. private static readonly string TenantIdValue = Tenant.ToString("N"); // ─── PredictAsync: маппинг и фолбэк ───────────────────────────────────── [Fact] public async Task PredictAsync_MapsFullReply_ToContractDtoAndSendsMetadata() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { service.PredictReply = new PredictReply { Take = true, Label = "b_junior", Scores = { { "b_junior", 0.95 }, { "b_senior", 0.3 } }, Hits = 3, Ready = true, Margin = 0.9, Terms = { "python", "middle" }, Type = new TypeDecision { Take = true, Label = "hire", Value = "t:hire", Margin = 0.5 }, }; IMlClient client = CreateClient(port); MlPredictResultDto result = await client.PredictAsync("нужен middle python разработчик", CancellationToken.None); Assert.True(result.Take); Assert.Equal("b_junior", result.Label); Assert.Equal(0.95, result.Scores["b_junior"]); Assert.Equal(0.3, result.Scores["b_senior"]); Assert.Equal(3, result.Hits); Assert.True(result.Ready); Assert.Equal(0.9, result.Margin); Assert.Equal(new[] { "python", "middle" }, result.Terms); Assert.NotNull(result.Type); Assert.True(result.Type.Take); Assert.Equal("hire", result.Type.Label); Assert.Equal("t:hire", result.Type.Value); Assert.Equal(0.5, result.Type.Margin); Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); Assert.Equal(MlGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens)); }); } [Fact] public async Task PredictAsync_ServiceUnavailable_ReturnsNotReadyPrediction() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { service.PredictUnavailable = true; IMlClient client = CreateClient(port); MlPredictResultDto result = await client.PredictAsync("текст", CancellationToken.None); Assert.False(result.Take); Assert.Null(result.Label); Assert.Empty(result.Scores); Assert.Equal(0, result.Hits); Assert.False(result.Ready); Assert.Null(result.Margin); Assert.Empty(result.Terms); Assert.Null(result.Type); Assert.Single(service.RequestTenantIds); }); } // ─── StatusAsync: статус сервиса + кэш 15 с + reachable ───────────────── [Fact] public async Task StatusAsync_FetchesServiceStatusAndMergesLocalStats() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { service.StatusReply = new StatusReply { Ready = true, Classes = { { "b_junior", 12.0 }, { "spam", 5.0 } }, Learned = 17, Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 }, }; var settings = new TestSettingsStore(); settings.Preload(SettingsKeys.MlDecisions, "7"); settings.Preload(SettingsKeys.AiDecisions, "3"); var learning = new TestMlLearningStore { LearningCount = 9 }; IMlClient client = CreateClient(port, settings, learning); MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); Assert.True(status.Enabled); // mlEnabled не задан — дефолт true Assert.True(status.Reachable); Assert.True(status.Service.Ready); Assert.Equal(17, status.Service.Learned); Assert.Equal(0.8, status.Service.Eval.Accuracy); Assert.Equal(7, status.Stats.Ml); Assert.Equal(3, status.Stats.Ai); Assert.Equal(9, status.Stats.Learning); Assert.Equal(0, status.Stats.Outbox); Assert.True(status.Stats.Ready); Assert.Equal(17, status.Stats.Learned); Assert.True(status.Stats.Reachable); Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); }); } [Fact] public async Task StatusAsync_ServiceDown_ServesCachedDataWithReachableFalse_ThenRecoversAfterTtl() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { DateTimeOffset now = DateTimeOffset.UtcNow; var cache = new MlStatusCache(() => now); IMlClient client = CreateClient(port, cache: cache); service.StatusUnavailable = true; MlStatusResponseDto down = await client.StatusAsync(CancellationToken.None); Assert.False(down.Reachable); Assert.False(down.Service.Ready); Assert.False(down.Stats.Reachable); Assert.Equal(1, service.StatusCalls); // «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true). service.StatusUnavailable = false; service.StatusReply = new StatusReply { Ready = true, Classes = { { "b_x", 1.0 } }, Learned = 3, Eval = new ModelEval { Count = 2, Correct = 2, Accuracy = 1.0 }, }; now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1); MlStatusResponseDto up = await client.StatusAsync(CancellationToken.None); Assert.True(up.Reachable); Assert.True(up.Service.Ready); Assert.Equal(3, up.Service.Learned); Assert.Equal(2, service.StatusCalls); }); } [Fact] public async Task StatusAsync_CachesWithinTtl_SingleFetch() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { DateTimeOffset now = DateTimeOffset.UtcNow; IMlClient client = CreateClient(port, cache: new MlStatusCache(() => now)); _ = await client.StatusAsync(CancellationToken.None); now = now.AddSeconds(5); // в пределах TTL 15 с — повторный вызов не ходит в сервис _ = await client.StatusAsync(CancellationToken.None); Assert.Equal(1, service.StatusCalls); now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1); // TTL истёк — новый fetch _ = await client.StatusAsync(CancellationToken.None); Assert.Equal(2, service.StatusCalls); }); } // ─── ResetAsync: gRPC Reset + очистка outbox только при успехе ────────── [Fact] public async Task ResetAsync_ServiceOk_ClearsOutboxAndInvalidatesStatusCache() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { var learning = new TestMlLearningStore(); learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0); learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0); IMlClient client = CreateClient(port, learning: learning); // Прогреть кэш статуса (до сброса — 1 вызов Status), затем сброс. _ = await client.StatusAsync(CancellationToken.None); MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); Assert.True(reset.Ok); Assert.Null(reset.Error); Assert.Equal(0, await learning.Store.CountOutboxAsync(CancellationToken.None)); // очередь очищена Assert.Equal(1, service.ResetCalls); _ = await client.StatusAsync(CancellationToken.None); Assert.Equal(2, service.StatusCalls); }); } [Fact] public async Task ResetAsync_ServiceSoftError_ReturnsOkFalseAndKeepsOutbox() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" }; var learning = new TestMlLearningStore(); learning.SeedOutbox("mle_1", "текст", "b_a", 1.0); IMlClient client = CreateClient(port, learning: learning); MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); Assert.False(reset.Ok); Assert.Equal("не удалось пересоздать файл модели", reset.Error); Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None)); }); } [Fact] public async Task ResetAsync_ServiceUnavailable_ReturnsOkFalseAndKeepsOutbox() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { service.ResetUnavailable = true; var learning = new TestMlLearningStore(); learning.SeedOutbox("mle_1", "текст", "spam", 1.0); IMlClient client = CreateClient(port, learning: learning); MlResetResultDto reset = await client.ResetAsync(CancellationToken.None); Assert.False(reset.Ok); Assert.Equal("ML-сервис недоступен", reset.Error); Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None)); }); } [Fact] public async Task PushAsync_WritesOutboxRow() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { var learning = new TestMlLearningStore(); IMlClient client = CreateClient(port, learning: learning); await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None); var row = Assert.Single(learning.AddedRows); Assert.StartsWith("mle_", row.Id, StringComparison.Ordinal); Assert.Equal("нужен python", row.Text); // trim Assert.Equal("b_junior", row.Label); Assert.Equal(1.0, row.Delta); Assert.Empty(service.RequestTenantIds); // push сервис не зовёт — только локальная запись }); } // ─── TrainBatchAsync (IMlTrainClient): батч для флашера ───────────────── [Fact] public async Task TrainBatchAsync_SendsItemsAndReturnsLearned() { await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) => { IMlClient client = CreateClient(port); var items = new[] { new MlOutboxEntryDto("mle_1", "текст 1", "b_a", 1.0), new MlOutboxEntryDto("mle_2", "текст 2", "spam", -1.0), }; int learned = await ((IMlTrainClient)client).TrainBatchAsync(items, CancellationToken.None); Assert.Equal(2, learned); TrainBatchRequest batch = Assert.Single(service.TrainBatches); Assert.Equal(2, batch.Items.Count); Assert.Equal("текст 1", batch.Items[0].Text); Assert.Equal("b_a", batch.Items[0].Label); Assert.Equal(1.0, batch.Items[0].Delta); Assert.Equal("текст 2", batch.Items[1].Text); Assert.Equal("spam", batch.Items[1].Label); Assert.Equal(-1.0, batch.Items[1].Delta); Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds)); }); } // ─── Хелперы ──────────────────────────────────────────────────────────── // Создаёт GrpcMlClient к хосту-фейку на эфемерном порту с пустыми фейками статистики. // port: Порт хоста-фейка ml-service. // settings: KV-хранилище тенанта (пустое — дефолты). // learning: Хранилище обучения (пустое). // cache: Кэш статуса (по умолчанию реальный — UtcNow). // Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста. private static GrpcMlClient CreateClient( int port, TestSettingsStore? settings = null, TestMlLearningStore? learning = null, MlStatusCache? cache = null) { settings ??= new TestSettingsStore(); learning ??= new TestMlLearningStore(); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }; return new GrpcMlClient( tenantContext, settings.Store, learning.Store, new MlGrpcConnection(options), cache ?? new MlStatusCache(), new TokenUsageRecorder( settings.Store, new TestTenantLimitStore().Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), NullLogger.Instance); } }