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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user