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,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;
}
}
}