Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

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 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,332 @@
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;
/// <summary>
/// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service.
/// </summary>
[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 FakeSettingsStore();
settings.Preload(SettingsKeys.MlDecisions, "7");
settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new FakeMlLearningStore { 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 FakeMlLearningStore();
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.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 FakeMlLearningStore();
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.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 FakeMlLearningStore();
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.CountOutboxAsync(CancellationToken.None));
});
}
[Fact]
public async Task PushAsync_WritesOutboxRow()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var learning = new FakeMlLearningStore();
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,
FakeSettingsStore? settings = null,
FakeMlLearningStore? learning = null,
MlStatusCache? cache = null)
{
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 ?? new FakeSettingsStore(),
learning ?? new FakeMlLearningStore(),
new MlGrpcConnection(options),
cache ?? new MlStatusCache(),
new TokenUsageRecorder(
settings ?? new FakeSettingsStore(),
new FakeTenantLimitStore(),
tenantContext,
new TokenUsageEventService(new FakeTokenUsageEventStore())),
NullLogger<GrpcMlClient>.Instance);
}
}