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,165 @@
|
||||
using Deal.Grpc.Ai;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Tests.Unit.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// In-proc фейк ai-service для тестов gRPC-адаптеров ядра
|
||||
/// </summary>
|
||||
public sealed class RecordingAiService : AiService.AiServiceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Ответ Filter по умолчанию
|
||||
/// </summary>
|
||||
public FilterReply FilterReply { get; set; } = new() { Pass = true };
|
||||
|
||||
/// <summary>
|
||||
/// Ответ Classify по умолчанию
|
||||
/// </summary>
|
||||
public ClassifyReply ClassifyReply { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ответ GenerateKeywords по умолчанию
|
||||
/// </summary>
|
||||
public GenerateKeywordsReply GenerateKeywordsReply { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ответ EvaluateFit по умолчанию
|
||||
/// </summary>
|
||||
public EvaluateFitReply EvaluateFitReply { get; set; } = new() { Fit = true };
|
||||
|
||||
/// <summary>
|
||||
/// Сбоить ли Filter статусом UNAVAILABLE
|
||||
/// </summary>
|
||||
public bool FilterUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сбоить ли Classify статусом UNAVAILABLE.
|
||||
/// </summary>
|
||||
public bool ClassifyUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сбоить ли GenerateKeywords статусом UNAVAILABLE.
|
||||
/// </summary>
|
||||
public bool KeywordsUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сбоить ли EvaluateFit статусом UNAVAILABLE.
|
||||
/// </summary>
|
||||
public bool FitUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число вызовов Filter.
|
||||
/// </summary>
|
||||
public int FilterCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число вызовов Classify.
|
||||
/// </summary>
|
||||
public int ClassifyCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число вызовов GenerateKeywords.
|
||||
/// </summary>
|
||||
public int KeywordsCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число вызовов EvaluateFit.
|
||||
/// </summary>
|
||||
public int FitCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Последний запрос Filter
|
||||
/// </summary>
|
||||
public FilterRequest? LastFilter { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Последний запрос Classify
|
||||
/// </summary>
|
||||
public ClassifyRequest? LastClassify { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Последний запрос GenerateKeywords.
|
||||
/// </summary>
|
||||
public GenerateKeywordsRequest? LastKeywords { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Последний запрос EvaluateFit.
|
||||
/// </summary>
|
||||
public EvaluateFitRequest? LastFit { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// tenant-id из metadata вызовов
|
||||
/// </summary>
|
||||
public List<string> RequestTenantIds { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// service-token из metadata вызовов
|
||||
/// </summary>
|
||||
public List<string> RequestTokens { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<FilterReply> Filter(FilterRequest request, ServerCallContext context)
|
||||
{
|
||||
FilterCalls++;
|
||||
RecordMetadata(context);
|
||||
if (FilterUnavailable)
|
||||
{
|
||||
throw Unavailable();
|
||||
}
|
||||
|
||||
LastFilter = request;
|
||||
return Task.FromResult(FilterReply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<ClassifyReply> Classify(ClassifyRequest request, ServerCallContext context)
|
||||
{
|
||||
ClassifyCalls++;
|
||||
RecordMetadata(context);
|
||||
if (ClassifyUnavailable)
|
||||
{
|
||||
throw Unavailable();
|
||||
}
|
||||
|
||||
LastClassify = request;
|
||||
return Task.FromResult(ClassifyReply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<GenerateKeywordsReply> GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context)
|
||||
{
|
||||
KeywordsCalls++;
|
||||
RecordMetadata(context);
|
||||
if (KeywordsUnavailable)
|
||||
{
|
||||
throw Unavailable();
|
||||
}
|
||||
|
||||
LastKeywords = request;
|
||||
return Task.FromResult(GenerateKeywordsReply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<EvaluateFitReply> EvaluateFit(EvaluateFitRequest request, ServerCallContext context)
|
||||
{
|
||||
FitCalls++;
|
||||
RecordMetadata(context);
|
||||
if (FitUnavailable)
|
||||
{
|
||||
throw Unavailable();
|
||||
}
|
||||
|
||||
LastFit = request;
|
||||
return Task.FromResult(EvaluateFitReply);
|
||||
}
|
||||
|
||||
private void RecordMetadata(ServerCallContext context)
|
||||
{
|
||||
RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty);
|
||||
RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty);
|
||||
}
|
||||
|
||||
private static RpcException Unavailable()
|
||||
=> new(new Status(StatusCode.Unavailable, "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд"));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using Deal.Grpc.Ml;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Tests.Unit.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// In-proc фейк ml-service для тестов gRPC-клиента ядра
|
||||
/// </summary>
|
||||
public sealed class RecordingMlService : MlService.MlServiceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Ответ Predict по умолчанию
|
||||
/// </summary>
|
||||
public PredictReply PredictReply { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ответ Status по умолчанию
|
||||
/// </summary>
|
||||
public StatusReply StatusReply { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ответ Reset по умолчанию
|
||||
/// </summary>
|
||||
public ResetReply ResetReply { get; set; } = new() { Ok = true };
|
||||
|
||||
/// <summary>
|
||||
/// Сбоить ли Predict статусом UNAVAILABLE
|
||||
/// </summary>
|
||||
public bool PredictUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сбоить ли Status статусом UNAVAILABLE.
|
||||
/// </summary>
|
||||
public bool StatusUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сбоить ли Reset статусом UNAVAILABLE.
|
||||
/// </summary>
|
||||
public bool ResetUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сбоить ли TrainBatch статусом UNAVAILABLE.
|
||||
/// </summary>
|
||||
public bool TrainUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число вызовов Predict.
|
||||
/// </summary>
|
||||
public int PredictCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число вызовов Status.
|
||||
/// </summary>
|
||||
public int StatusCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число вызовов Reset.
|
||||
/// </summary>
|
||||
public int ResetCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Число вызовов TrainBatch.
|
||||
/// </summary>
|
||||
public int TrainCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Полученные батчи обучения
|
||||
/// </summary>
|
||||
public List<TrainBatchRequest> TrainBatches { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// tenant-id из metadata вызовов
|
||||
/// </summary>
|
||||
public List<string> RequestTenantIds { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// service-token из metadata вызовов
|
||||
/// </summary>
|
||||
public List<string> RequestTokens { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<PredictReply> Predict(PredictRequest request, ServerCallContext context)
|
||||
{
|
||||
PredictCalls++;
|
||||
RecordMetadata(context);
|
||||
if (PredictUnavailable)
|
||||
{
|
||||
throw Unavailable();
|
||||
}
|
||||
|
||||
return Task.FromResult(PredictReply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<StatusReply> Status(StatusRequest request, ServerCallContext context)
|
||||
{
|
||||
StatusCalls++;
|
||||
RecordMetadata(context);
|
||||
if (StatusUnavailable)
|
||||
{
|
||||
throw Unavailable();
|
||||
}
|
||||
|
||||
return Task.FromResult(StatusReply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<ResetReply> Reset(ResetRequest request, ServerCallContext context)
|
||||
{
|
||||
ResetCalls++;
|
||||
RecordMetadata(context);
|
||||
if (ResetUnavailable)
|
||||
{
|
||||
throw Unavailable();
|
||||
}
|
||||
|
||||
return Task.FromResult(ResetReply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<TrainBatchReply> TrainBatch(TrainBatchRequest request, ServerCallContext context)
|
||||
{
|
||||
TrainCalls++;
|
||||
RecordMetadata(context);
|
||||
if (TrainUnavailable)
|
||||
{
|
||||
throw Unavailable();
|
||||
}
|
||||
|
||||
TrainBatches.Add(request);
|
||||
return Task.FromResult(new TrainBatchReply { Learned = request.Items.Count });
|
||||
}
|
||||
|
||||
private void RecordMetadata(ServerCallContext context)
|
||||
{
|
||||
RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty);
|
||||
RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty);
|
||||
}
|
||||
|
||||
private static RpcException Unavailable()
|
||||
=> new(new Status(StatusCode.Unavailable, "ml-service недоступен (тест)"));
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using Deal.Grpc.Telegram;
|
||||
using Grpc.Core;
|
||||
|
||||
namespace Deal.Tests.Unit.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Фейк-сервер TelegramService
|
||||
/// </summary>
|
||||
public sealed class RecordingTelegramService : TelegramService.TelegramServiceBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Последний запрос StartPhone
|
||||
/// </summary>
|
||||
public StartPhoneRequest? LastStartPhone { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Последний запрос StartQr
|
||||
/// </summary>
|
||||
public StartQrRequest? LastStartQr { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Запросы Backfill
|
||||
/// </summary>
|
||||
public List<BackfillRequest> BackfillRequests { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Запросы ReadRecent
|
||||
/// </summary>
|
||||
public List<ReadRecentRequest> ReadRecentRequests { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Запросы ReadSource
|
||||
/// </summary>
|
||||
public List<ReadSourceRequest> ReadSourceRequests { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// found ответа ReadSource
|
||||
/// </summary>
|
||||
public bool ReadSourceFound { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// text ответа ReadSource (null — поле не задано)
|
||||
/// </summary>
|
||||
public string? ReadSourceText { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// time ответа ReadSource (null — поле не задано)
|
||||
/// </summary>
|
||||
public long? ReadSourceTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Каталог ответа RefreshDialogs
|
||||
/// </summary>
|
||||
public List<DialogEntry> Catalog { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Сколько сообщений «разобрал» Backfill
|
||||
/// </summary>
|
||||
public int BackfillProcessed { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Доменный сбой следующего вызова
|
||||
/// </summary>
|
||||
public RpcException? DomainFailure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Фаза/поля ответа GetStatus.
|
||||
/// </summary>
|
||||
public string StatusPhase { get; set; } = "idle";
|
||||
|
||||
/// <summary>
|
||||
/// Аккаунт ответа GetStatus
|
||||
/// </summary>
|
||||
public string StatusAccount { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Connected ответа GetStatus.
|
||||
/// </summary>
|
||||
public bool StatusConnected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Listener ответа GetStatus.
|
||||
/// </summary>
|
||||
public bool StatusListener { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// qrUrl ответа GetStatus/StartQr
|
||||
/// </summary>
|
||||
public string QrUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// error ответа GetStatus
|
||||
/// </summary>
|
||||
public string StatusError { get; set; } = string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<GetStatusReply> GetStatus(GetStatusRequest request, ServerCallContext context)
|
||||
{
|
||||
ThrowIfDomainFailure(context);
|
||||
var reply = new GetStatusReply
|
||||
{
|
||||
Phase = StatusPhase,
|
||||
Connected = StatusConnected,
|
||||
Listener = StatusListener,
|
||||
Account = StatusAccount,
|
||||
};
|
||||
if (StatusError.Length > 0)
|
||||
{
|
||||
reply.Error = StatusError;
|
||||
}
|
||||
|
||||
if (QrUrl.Length > 0)
|
||||
{
|
||||
reply.QrUrl = QrUrl;
|
||||
}
|
||||
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<StartPhoneReply> StartPhone(StartPhoneRequest request, ServerCallContext context)
|
||||
{
|
||||
ThrowIfDomainFailure(context);
|
||||
LastStartPhone = request;
|
||||
return Task.FromResult(new StartPhoneReply { Phase = "code" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<StartQrReply> StartQr(StartQrRequest request, ServerCallContext context)
|
||||
{
|
||||
ThrowIfDomainFailure(context);
|
||||
LastStartQr = request;
|
||||
var reply = new StartQrReply { Phase = "qr" };
|
||||
if (QrUrl.Length > 0)
|
||||
{
|
||||
reply.QrUrl = QrUrl;
|
||||
}
|
||||
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<RefreshDialogsReply> RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context)
|
||||
{
|
||||
ThrowIfDomainFailure(context);
|
||||
var reply = new RefreshDialogsReply();
|
||||
reply.Entries.AddRange(Catalog);
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<BackfillReply> Backfill(BackfillRequest request, ServerCallContext context)
|
||||
{
|
||||
ThrowIfDomainFailure(context);
|
||||
BackfillRequests.Add(request);
|
||||
return Task.FromResult(new BackfillReply { Processed = BackfillProcessed });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<ReadRecentReply> ReadRecent(ReadRecentRequest request, ServerCallContext context)
|
||||
{
|
||||
ThrowIfDomainFailure(context);
|
||||
ReadRecentRequests.Add(request);
|
||||
var reply = new ReadRecentReply();
|
||||
reply.Messages.Add(new PreviewMessage { Id = "100500", Text = "Свежее", Time = 1700000000000 });
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<ReadSourceReply> ReadSource(ReadSourceRequest request, ServerCallContext context)
|
||||
{
|
||||
ThrowIfDomainFailure(context);
|
||||
ReadSourceRequests.Add(request);
|
||||
var reply = new ReadSourceReply { Found = ReadSourceFound };
|
||||
if (ReadSourceText is not null)
|
||||
{
|
||||
reply.Text = ReadSourceText;
|
||||
}
|
||||
|
||||
if (ReadSourceTime is not null)
|
||||
{
|
||||
reply.Time = ReadSourceTime.Value;
|
||||
}
|
||||
|
||||
return Task.FromResult(reply);
|
||||
}
|
||||
|
||||
// Бросает доменный сбой сценария (если задан) — как RPC-ошибка telegram-service.
|
||||
// context: Контекст вызова (не используется, но держит сигнатуру единообразной).
|
||||
private void ThrowIfDomainFailure(ServerCallContext context)
|
||||
{
|
||||
if (DomainFailure is not null)
|
||||
{
|
||||
throw DomainFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user