Сгруппировать тестовые проекты по областям

Deal.Tests.Unit, Deal.Telegram.Tests, Deal.Ai.Tests, Deal.Ml.Tests
разложены по областям (Modules/<X>, Api, Infrastructure, Contracts,
Grpc, ...), общие хелперы -> Support; namespace = папка, using
между областями добавлены итеративно по ошибкам сборки.
This commit is contained in:
Rustam Khalimov
2026-09-11 13:28:26 +03:00
parent e3a2692507
commit 5bfa92a4ac
212 changed files with 1982 additions and 1686 deletions
@@ -0,0 +1,117 @@
using Deal.Api.Configuration;
using Deal.Api.Hosting;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Tenants;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты логики прохода <see cref="DataRetentionScheduler"/> (этап 12, пакет B): авто-очистка
/// audit_log по retention, сброс накопительных полей tenant_limits прошедших периодов и уборка
/// завершившихся окон распределённых счётчиков. Тайминги цикла не тестируются — итерация через публичный
/// <see cref="DataRetentionScheduler.RunCycleAsync"/>.
/// </summary>
/// <remarks>
/// Скоупы/DI — реальный ServiceCollection с фейками хранилищ (<see cref="FakeAuditLogStore"/>,
/// <see cref="FakeTenantLimitStore"/>, <see cref="FakeRateLimitCounterStore"/>), зеркалящими семантику
/// EF-адаптеров. Срок хранения аудита — <see cref="DataRetentionOptions.AuditRetentionDays"/>.
/// </remarks>
public sealed class DataRetentionSchedulerTests
{
// Тенант сценария (строка лимита).
private static readonly Guid TenantId = Guid.NewGuid();
// Срок хранения аудита сценария (дней).
private const int RetentionDays = 180;
[Fact]
public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
var limits = new FakeTenantLimitStore();
// Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться.
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true);
var counters = new FakeRateLimitCounterStore();
await counters.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None);
await counters.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None);
await using ServiceProvider provider = BuildProvider(audit, limits, counters);
DataRetentionScheduler scheduler = new(
provider.GetRequiredService<IServiceScopeFactory>(),
new DataRetentionOptions { Enabled = true, AuditRetentionDays = RetentionDays },
NullLogger<DataRetentionScheduler>.Instance);
await scheduler.RunCycleAsync(CancellationToken.None);
// Аудит: старая запись удалена, свежая осталась.
var remainingAudit = Assert.Single(audit.Records);
Assert.Equal(AuditEvents.TenantLoginOk, remainingAudit.EventType);
// Лимиты: накопления прошедшего периода сброшены.
Assert.Equal(0, limits.UsedTokens(TenantId));
// Счётчики: завершившееся окно удалено, активное осталось.
Assert.Equal(0, await counters.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None));
Assert.Equal(1, await counters.GetCountAsync("active", now, CancellationToken.None));
}
/// <summary>
/// Повторный проход на тех же данных — идемпотентен (чистить больше нечего).
/// </summary>
[Fact]
public async Task RunCycle_IsIdempotent()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var audit = new FakeAuditLogStore();
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var limits = new FakeTenantLimitStore();
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
await using ServiceProvider provider = BuildProvider(audit, limits, new FakeRateLimitCounterStore());
DataRetentionScheduler scheduler = new(
provider.GetRequiredService<IServiceScopeFactory>(),
new DataRetentionOptions { AuditRetentionDays = RetentionDays },
NullLogger<DataRetentionScheduler>.Instance);
await scheduler.RunCycleAsync(CancellationToken.None);
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Empty(audit.Records);
Assert.Equal(0, limits.UsedTokens(TenantId));
}
// Строит DI-провайдер теста: три фейк-хранилища в scope прохода.
// audit: Фейк-хранилище аудита (записи посеяны сценарием).
// limits: Фейк-хранилище лимитов (строки посеяны сценарием).
// counters: Фейк-хранилище счётчиков (окна посеяны сценарием).
// Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider(
FakeAuditLogStore audit,
FakeTenantLimitStore limits,
FakeRateLimitCounterStore counters)
{
var services = new ServiceCollection();
services.AddScoped<IAuditLogStore>(_ => audit);
services.AddScoped<ITenantLimitStore>(_ => limits);
services.AddScoped<IRateLimitCounterStore>(_ => counters);
return services.BuildServiceProvider();
}
// Запись аудита сценария.
// eventType: Тип события.
// at: Момент события (UTC).
// Возвращает: DTO записи аудита.
private static AuditRecordDto AuditRecord(string eventType, DateTimeOffset at) =>
new(eventType, AuditActorTypes.Operator, ActorId: null, TenantId: null, Ip: "127.0.0.1", DetailJson: null, at);
}
@@ -0,0 +1,72 @@
using Deal.Api.Endpoints;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты чистых хелперов эндпоинтов /api/discovery (план Task 19, Ruling 11): очистка ключей
/// ответа ИИ (python _clean_keywords discovery_routes.py L111128).
/// </summary>
public sealed class DiscoveryEndpointsHelpersTests
{
/// <summary>
/// Null/пустой ответ — пустой список (python L115: raw or []).
/// </summary>
[Fact]
public void CleanKeywords_NullOrEmpty_ReturnsEmptyList()
{
Assert.Empty(DiscoveryEndpoints.CleanKeywords(null));
Assert.Empty(DiscoveryEndpoints.CleanKeywords([]));
}
/// <summary>
/// Строки тримятся; пустые/пробельные и не-строки отбрасываются (python L117120).
/// </summary>
[Fact]
public void CleanKeywords_TrimsAndDropsEmpty()
{
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(
[" кл юч ", " ", string.Empty, "тест"]);
Assert.Equal(["кл юч", "тест"], keywords);
}
/// <summary>
/// Ключ длиннее 60 символов отбрасывается (python L120: len(keyword) &gt; _KEYWORD_LENGTH_LIMIT).
/// </summary>
[Fact]
public void CleanKeywords_DropsOverlongKeyword()
{
string tooLong = new('д', 61);
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(["норм", tooLong, "ещё"]);
Assert.Equal(["норм", "ещё"], keywords);
}
/// <summary>
/// Повторы (casefold) схлопываются, сохраняется первое вхождение (python L121126).
/// </summary>
[Fact]
public void CleanKeywords_DeduplicatesCaseInsensitiveKeepingFirst()
{
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(["КЛЮЧ", "ключ", "Другой", "дРУгОй"]);
Assert.Equal(["КЛЮЧ", "Другой"], keywords);
}
/// <summary>
/// Страховочный потолок — 30 ключей (python L127128: break на лимите).
/// </summary>
[Fact]
public void CleanKeywords_CapsAtThirty()
{
string[] raw = Enumerable.Range(0, 40).Select(i => $"ключ{i}").ToArray();
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(raw);
Assert.Equal(30, keywords.Count);
Assert.Equal("ключ0", keywords[0]);
Assert.Equal("ключ29", keywords[^1]);
}
}
@@ -0,0 +1,226 @@
using Deal.Api.Hosting;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Data;
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Extensions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Discovery;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Tenants;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты DiscoveryWorkerScheduler — фоновый цикл воркера Discovery (план Task 18, Ruling 10; эталон
/// PipelineWorkerSchedulerTests): каждые 5 с обход ВСЕХ тенантов реестра, на каждый — собственный scope с
/// ITenantContext и один тик DiscoveryWorkerService.
/// </summary>
/// <remarks>
/// Тайминги цикла (Timer 5 с, первый проход, stop) не тестируются — тестируется тело прохода RunCycleAsync
/// (как PipelineWorkerSchedulerTests). Провайдер собирает РЕАЛЬНЫЕ сервисы модуля Discovery (AddDiscoveryModule)
/// на тенант-фейках (FakeDiscoveryStore/FakeSettingsStore/FakeDiscoveryGateway по ITenantContext): воркер ходит
/// тем же путём, что и в проде (SetTenant → scoped-резолв → TickOnce). ML/ИИ в настройках тенантов выключены —
/// оценка идёт эвристикой по ключам.
/// </remarks>
public sealed class DiscoveryWorkerSchedulerTests
{
// Тенант A теста.
private static readonly Guid TenantA = Guid.NewGuid();
// Тенант B теста.
private static readonly Guid TenantB = Guid.NewGuid();
// Контекст теста: планировщик на общих фейках + хранилища/гейты тенантов.
private sealed record Context(
DiscoveryWorkerScheduler Scheduler,
FakeDiscoveryStore StoreA,
FakeDiscoveryStore StoreB,
FakeDiscoveryGateway GatewayA,
FakeDiscoveryGateway GatewayB,
TenantContext TenantContext,
ListLogger Logs);
[Fact]
public async Task RunCycle_TicksEveryTenantInOwnScope()
{
Context ctx = CreateContext();
// A: план вступлений выполнен (joined ≥ planJoins) → done. B: поиск по ключу → кандидат.
ctx.StoreA.SeedTask(Task("dt_A", joined: 2, planJoins: 2));
ctx.StoreB.SeedTask(Task("dt_B", keywords: ["заказ B"], searchDone: false));
ctx.GatewayB.SearchResults = [Entry("dB", "Канал B", "ch_b", DiscoveryCandidateKinds.Channel)];
await ctx.Scheduler.RunCycleAsync(CancellationToken.None);
// Оба тенанта оттикали в собственных scope: A — done (терминальный), B — кандидат от поиска.
Assert.Equal(DiscoveryTaskStatuses.Done, ctx.StoreA.Tasks.Single().Status);
Assert.Contains(ctx.StoreA.Log, log => log.Event == DiscoveryLogEvents.Done);
Assert.Equal(["заказ B"], ctx.GatewayB.SearchedKeywords);
DiscoveryCandidateDto candidateB = Assert.Single(ctx.StoreB.Candidates);
Assert.Equal("dB", candidateB.DialogId);
// Изоляция: тенант A не трогал поиск/гейт B и наоборот; логи живут в своих хранилищах.
Assert.Empty(ctx.GatewayA.SearchedKeywords);
Assert.Empty(ctx.StoreA.Candidates);
Assert.DoesNotContain(ctx.StoreA.Log, log => log.TaskId == "dt_B");
Assert.DoesNotContain(ctx.StoreB.Log, log => log.TaskId == "dt_A");
// Контекст AsyncLocal не должен переживать проход (Reset в finally каждого тика).
Assert.False(ctx.TenantContext.HasTenant);
}
[Fact]
public async Task RunCycle_NoWork_QuietNoopAndNoTenantLeak()
{
Context ctx = CreateContext();
await ctx.Scheduler.RunCycleAsync(CancellationToken.None);
Assert.Empty(ctx.StoreA.Tasks);
Assert.Empty(ctx.StoreB.Tasks);
Assert.Empty(ctx.GatewayA.SearchedKeywords);
Assert.Empty(ctx.GatewayB.SearchedKeywords);
Assert.False(ctx.TenantContext.HasTenant);
}
// ─── Контекст и хелперы ─────────────────────────────────────────────────
// Собирает планировщик на реальных сервисах модуля Discovery и тенант-фейках (без таймера).
private static Context CreateContext()
{
var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
var storeA = new FakeDiscoveryStore();
var storeB = new FakeDiscoveryStore();
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var gatewayA = new FakeDiscoveryGateway();
var gatewayB = new FakeDiscoveryGateway();
PreloadDiscoveryEvalOff(settingsA);
PreloadDiscoveryEvalOff(settingsB);
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton<ITenantRepository>(tenants);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IAiTools>(new FakeAiTools());
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA : storeB);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA : gatewayB);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddDiscoveryModule();
ServiceProvider provider = services.BuildServiceProvider();
var logs = new ListLogger();
var scheduler = new DiscoveryWorkerScheduler(
provider.GetRequiredService<IServiceScopeFactory>(),
logs);
return new Context(scheduler, storeA, storeB, gatewayA, gatewayB, tenantContext, logs);
}
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings)
{
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
}
// Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant).
private static Guid TenantOf(IServiceProvider provider)
{
TenantId tenantId = provider.GetRequiredService<ITenantContext>().TenantId
?? throw new InvalidOperationException("Тест: тик вне tenant-контекста (SetTenant не выполнен)");
return Guid.Parse(tenantId.Value);
}
// Запись реестра тенанта (как строка public.tenants).
private static TenantRecordDto Tenant(Guid id) =>
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
// Задача как строка DiscTasks (адаптер-форма).
private static DiscoveryTaskDto Task(
string id,
string[]? keywords = null,
bool searchDone = true,
int planJoins = 5,
int joined = 0)
{
return new DiscoveryTaskDto(
id,
"Задача",
string.Empty,
keywords ?? new[] { "заказ" },
MinSubscribers: 0,
Lang: "ru",
Threshold: 40,
SampleSize: 10,
planJoins,
AutoJoin: false,
Status: DiscoveryTaskStatuses.Running,
SearchIdx: 0,
searchDone,
Found: 0,
Evaluated: 0,
joined,
Rejected: 0,
CreatedAt: 1000,
UpdatedAt: 1000);
}
// Запись результата глобального поиска (proto DialogEntry).
private static TelegramDialogEntryDto Entry(
string id,
string name,
string handle,
string kind)
=> new(id, name, handle, kind, "#666");
// Логгер-коллектор: копит сообщения планировщика (диагностика в тестах цикла).
private sealed class ListLogger : ILogger<DiscoveryWorkerScheduler>
{
/// <summary>
/// Отформатированные сообщения лога в порядке записи.
/// </summary>
public List<string> Messages { get; } = [];
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}
@@ -0,0 +1,169 @@
using Deal.Api.Configuration;
using Deal.Api.Middleware;
using Deal.Grpc.Telegram;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
using Microsoft.Extensions.DependencyInjection;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Modules.Kanban;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// In-proc gRPC-тесты IngressRateLimitInterceptor (план Task 11, Ruling 5): фиксированное окно
/// (GrpcIngressPerMinute в минуту) по tenant-id из metadata — превышение → RESOURCE_EXHAUSTED;
/// у разных тенантов собственные окна; стандартный grpc.health.v1.Health лимитом не режется.
/// </summary>
/// <remarks>
/// Хост — TelegramIngressTestHost с Enabled-опциями (добавляет интерцептор, общий singleton-лимитер
/// и grpc.health.v1, как в Program.cs). Все вызовы несут service-token сценария.
/// </remarks>
public sealed class IngressRateLimitInterceptorTests
{
// Токен сценариев теста.
private const string ValidToken = TelegramIngressTestHost.DefaultToken;
// Тенант A сценариев (в реестре).
private static readonly Guid TenantA = Guid.NewGuid();
// Тенант B сценариев (в реестре) — проверка изоляции окон.
private static readonly Guid TenantB = Guid.NewGuid();
// Id диалога сценариев PushMessage.
private const string DialogId = "d_channel_rate_limit";
// Окно сценария по умолчанию: 2/мин — быстрый тест без ожидания реального окна.
private const int DefaultPermitsPerMinute = 2;
// Окно health-сценария: 1/мин — единственный вызов исчерпывает лимит.
private const int HealthScenarioPermitsPerMinute = 1;
// ─── Окно интерцептора ─────────────────────────────────────────────────
/// <summary>
/// 3-й вызов тенанта в минуту (окно 2/мин) — RPC RESOURCE_EXHAUSTED до метода сервиса;
/// другой тенант имеет собственное окно и проходит (партиция по tenant-id).
/// </summary>
[Fact]
public async Task PushMessage_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses()
{
var registry = new FakeTenantRegistry(Tenant(TenantA), Tenant(TenantB));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
async channel =>
{
PushMessageReply first = await PushAsync(channel, TenantA);
PushMessageReply second = await PushAsync(channel, TenantA);
Assert.True(first.Accepted);
Assert.True(second.Accepted);
RpcException rejection = await Assert.ThrowsAsync<RpcException>(() => PushAsync(channel, TenantA));
Assert.Equal(StatusCode.ResourceExhausted, rejection.StatusCode);
Assert.Equal(RateLimitPolicies.RejectedDetail, rejection.Status.Detail);
// Окно TenantB — отдельная партиция: вызов проходит.
PushMessageReply tenantB = await PushAsync(channel, TenantB);
Assert.True(tenantB.Accepted);
});
}
/// <summary>
/// Health (grpc.health.v1) освобождён от лимита: при исчерпанном окне ингресса health отвечает SERVING.
/// </summary>
[Fact]
public async Task HealthCheck_IsNotRateLimited_WhenIngressWindowExhausted()
{
var registry = new FakeTenantRegistry(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
async channel =>
{
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
PushMessageReply push = await PushAsync(channel, TenantA);
Assert.True(push.Accepted);
// Health не потребляет и не режется лимитом ингресса (liveness инфраструктуры, Ruling 5).
var healthClient = new Health.HealthClient(channel);
HealthCheckResponse health = await healthClient.CheckAsync(
new HealthCheckRequest { Service = string.Empty },
deadline: Deadline());
Assert.Equal(HealthCheckResponse.Types.ServingStatus.Serving, health.Status);
// Лимит ингресса по-прежнему исчерпан (health окно не тронул).
RpcException rejection = await Assert.ThrowsAsync<RpcException>(() => PushAsync(channel, TenantA));
Assert.Equal(StatusCode.ResourceExhausted, rejection.StatusCode);
},
permitsPerMinute: HealthScenarioPermitsPerMinute);
}
// ─── Контекст и хелперы ─────────────────────────────────────────────────
// Запись реестра тенанта (как строка public.tenants).
// id: Идентификатор тенанта.
private static TenantRecordDto Tenant(Guid id) =>
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
// Поднимает хост ингресса с включённым лимитом (окно permitsPerMinute на tenant-id).
// registry: Реестр тенантов сценария.
// registerTenantServices: Дополнительные tenant-scoped адаптеры сценария (IPipelineStore).
// scenario: Сценарий с gRPC-каналом.
// permitsPerMinute: Окно сценария (GrpcIngressPerMinute опций хоста).
private static Task RunAsync(
FakeTenantRegistry registry,
Action<IServiceCollection> registerTenantServices,
Func<GrpcChannel, Task> scenario,
int permitsPerMinute = DefaultPermitsPerMinute)
=> TelegramIngressTestHost.RunAsync(
ValidToken,
services =>
{
services.AddSingleton<ITenantRepository>(registry);
registerTenantServices(services);
},
scenario,
rateLimitOptions: new RateLimitOptions
{
Enabled = true,
GrpcIngressPerMinute = permitsPerMinute,
});
// Вызывает PushMessage с metadata сценария (service-token + tenant-id, deadline 10 с).
// channel: Канал к хосту.
// tenantId: Id тенанта в metadata.
private static Task<PushMessageReply> PushAsync(GrpcChannel channel, Guid tenantId)
{
var client = new IngressService.IngressServiceClient(channel);
var request = new PushMessageRequest
{
DialogId = DialogId,
ChannelName = "Канал",
ChannelHandle = "kanal_handle",
ChannelHue = "#a33",
Text = "Сообщение сценария лимита",
MsgId = 1,
};
AsyncUnaryCall<PushMessageReply> call = client.PushMessageAsync(
request,
new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline()));
return call.ResponseAsync;
}
// Deadline вызовов теста (контракт ингресса — 10 с).
private static DateTime Deadline()
=> DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds);
}
@@ -0,0 +1,215 @@
using Deal.Api.Configuration;
using Deal.Api.Extensions;
using Deal.Api.Models;
using Deal.Api.Services;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Tenants;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Unit-тесты LoginAttemptGuard (план Task 11, Ruling 5; этап 12, пакет B — хранилище Postgres):
/// фиксированное окно по ключу ip|login — 5 неудач за 15 минут блокируют следующий вход; успех
/// сбрасывает счётчик; окно истекает; ключи разных ip/login изолированы; при RateLimit:Enabled=false
/// (dev/тесты) гвард выключен.
/// </summary>
/// <remarks>
/// Хранилище — <see cref="FakeRateLimitCounterStore"/> (семантика public.rate_limit_counters); часы —
/// инъекцией Func&lt;DateTimeOffset&gt;: границы окна тестируются на фиксированном «сейчас» без ожидания.
/// </remarks>
public sealed class LoginAttemptGuardTests
{
// IP клиента сценариев.
private const string Ip = "203.0.113.10";
// IP другого клиента (проверка изоляции ключей).
private const string OtherIp = "198.51.100.20";
// Логин сценариев (нормализованный, как в эндпоинтах).
private const string Login = "user@example.com";
// Другой логин (проверка изоляции ключей).
private const string OtherLogin = "another@example.com";
// ─── Порог блокировки ──────────────────────────────────────────────────
/// <summary>
/// 5 неудач подряд в окне — следующий вход ключа блокирован (Ruling 5: ≥5 → блок).
/// </summary>
[Fact]
public async Task FiveFailuresWithinWindow_BlockNextAttempt()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var guard = NewGuard(() => now);
for (int attempt = 0; attempt < 5; attempt++)
{
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
}
Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
}
/// <summary>
/// 4 неудачи — порог не достигнут: попытка ещё разрешена.
/// </summary>
[Fact]
public async Task FourFailuresWithinWindow_AreNotBlocked()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var guard = NewGuard(() => now);
for (int attempt = 0; attempt < 4; attempt++)
{
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
}
Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
}
// ─── Сброс при успехе (Ruling 5) ───────────────────────────────────────
/// <summary>
/// Успешный вход сбрасывает счётчик: после части неудач и Reset ключ снова проходит полные 5.
/// </summary>
[Fact]
public async Task SuccessReset_ClearsFailures_AndWindowStartsAnew()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var guard = NewGuard(() => now);
for (int attempt = 0; attempt < 3; attempt++)
{
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
}
Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
await guard.ResetAsync(Ip, Login, CancellationToken.None);
Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
for (int attempt = 0; attempt < 5; attempt++)
{
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
}
// Сброс реально очистил счётчик: до блока — снова полные 5 неудач, а не 2 (3 старых + 2 новых).
Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
}
/// <summary>
/// Уже заблокированный ключ разблокируется успешным входом (сброс снимает блок).
/// </summary>
[Fact]
public async Task SuccessReset_UnblocksBlockedKey()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var guard = NewGuard(() => now);
for (int attempt = 0; attempt < 5; attempt++)
{
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
}
Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
await guard.ResetAsync(Ip, Login, CancellationToken.None);
Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
Assert.False(await guard.IsBlockedAsync(Ip, OtherLogin, CancellationToken.None));
}
// ─── Истечение окна ────────────────────────────────────────────────────
/// <summary>
/// После завершения фиксированного окна блокировка снимается и отсчёт начинается заново.
/// </summary>
[Fact]
public async Task WindowExpiry_UnblocksKeyAndStartsFreshWindow()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var guard = NewGuard(() => now);
for (int attempt = 0; attempt < 5; attempt++)
{
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
}
Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
// Сдвиг больше длины окна (15 мин) гарантированно переводит в новое фиксированное окно.
now = now.AddMinutes(16);
Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
// Новая неудача в новом окне не восстанавливает старый счётчик.
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
}
// ─── Изоляция ключей ───────────────────────────────────────────────────
/// <summary>
/// Блокировка ключа ip|login не затрагивает другие IP и другие логины.
/// </summary>
[Fact]
public async Task BlockedKey_DoesNotAffectOtherIpsOrLogins()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var guard = NewGuard(() => now);
for (int attempt = 0; attempt < 5; attempt++)
{
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
}
Assert.True(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
Assert.False(await guard.IsBlockedAsync(Ip, OtherLogin, CancellationToken.None));
Assert.False(await guard.IsBlockedAsync(OtherIp, Login, CancellationToken.None));
Assert.False(await guard.IsBlockedAsync(OtherIp, OtherLogin, CancellationToken.None));
}
// ─── Флаг Enabled (dev/тесты) ──────────────────────────────────────────
/// <summary>
/// RateLimit:Enabled=false (дефолт dev/тестов) — гвард выключен: неудачи не копятся, блокировок нет.
/// </summary>
[Fact]
public async Task DisabledByDefaultInDev_DoesNotCountOrBlock()
{
var guard = new LoginAttemptGuard(new RateLimitOptions(), new FakeRateLimitCounterStore());
for (int attempt = 0; attempt < 10; attempt++)
{
await guard.RecordFailureAsync(Ip, Login, CancellationToken.None);
}
Assert.False(await guard.IsBlockedAsync(Ip, Login, CancellationToken.None));
}
/// <summary>
/// Пустой/пробельный логин ключа не имеет — блокировке не подлежит (нет ключа ip|"").
/// </summary>
[Fact]
public async Task EmptyOrWhitespaceLogin_IsNeverBlocked()
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var guard = NewGuard(() => now);
for (int attempt = 0; attempt < 10; attempt++)
{
await guard.RecordFailureAsync(Ip, string.Empty, CancellationToken.None);
}
Assert.False(await guard.IsBlockedAsync(Ip, string.Empty, CancellationToken.None));
Assert.False(await guard.IsBlockedAsync(Ip, " ", CancellationToken.None));
}
// Создаёт включённый гвард (Enabled=true, дефолты 5/15) с фейк-хранилищем и инъекцией часов.
// clock: Источник «сейчас» (лямбда захватывает mutable-локальную теста — сдвиг окна виден гварду).
// Возвращает: Гвард с инъекцией часов.
private static LoginAttemptGuard NewGuard(Func<DateTimeOffset> clock) =>
new(
new RateLimitOptions { Enabled = true },
new FakeRateLimitCounterStore(),
clock);
}
@@ -0,0 +1,238 @@
using Deal.Api.Hosting;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Extensions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты MlOutboxFlushScheduler — логика прохода цикла выгрузки обучения ML (план Task 16, Ruling 6;
/// аналог flush_outbox ml_client.py L5682): обход всех тенантов реестра, в собственном scope каждого —
/// порции по 10 строк (≤100/цикл) в ml-service (TrainBatch на реальном канале к фейк-серверу), удаление строк
/// ТОЛЬКО после успеха; при недоступности сервиса строки остаются (ретрай на следующем цикле).
/// </summary>
/// <remarks>
/// Тайминги цикла (Timer 10 с) не тестируются — тестируется итерация через публичный
/// <see cref="MlOutboxFlushScheduler.RunCycleAsync"/>. DI-провайдер поднимается на реальном ServiceCollection:
/// ITenantRepository — фейк, IMlLearningStore/ISettingsStore выбираются по текущему ITenantContext (как
/// реальные адаптеры, строящие TenantDbContext от схемы тенанта); IMlTrainClient — реальный GrpcMlClient
/// к in-proc фейк-ml-service (проверяются metadata/маппинг TrainBatch «по проводу»). Сеть наружу не используется.
/// </remarks>
[Collection("MlGrpcTests")]
public sealed class MlOutboxFlushSchedulerTests
{
// Тенант A теста.
private static readonly Guid TenantA = Guid.NewGuid();
// Тенант B теста.
private static readonly Guid TenantB = Guid.NewGuid();
[Fact]
public async Task RunCycle_FlushesTwentyFiveRowsInThreeBatchesAndDeletesRows()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var store = new FakeMlLearningStore();
SeedRows(store, count: 25, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new FakeTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
Assert.Equal(3, service.TrainCalls);
Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
Assert.Equal(0, await store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход
});
}
[Fact]
public async Task RunCycle_ServiceUnavailable_KeepsRowsAndRetriesOnNextCycle()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.TrainUnavailable = true;
var store = new FakeMlLearningStore();
SeedRows(store, count: 5, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new FakeTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); // строки остались
// Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены).
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(2, service.TrainCalls);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant);
});
}
[Fact]
public async Task RunCycle_TwoTenants_FlushIndependentStoresInOwnScopes()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var storeA = new FakeMlLearningStore();
SeedRows(storeA, count: 12, prefix: "a");
var storeB = new FakeMlLearningStore();
SeedRows(storeB, count: 3, prefix: "b");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
Assert.Equal(3, service.TrainCalls);
Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
Assert.Equal(0, await storeA.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant);
});
}
[Fact]
public async Task RunCycle_LongQueue_FlushesAtMostHundredRowsPerCycle()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var store = new FakeMlLearningStore();
SeedRows(store, count: 105, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new FakeTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
// Потолок 100/цикл (flush_outbox L56): 10 батчей по 10, остаток 5 ждёт следующего цикла.
Assert.Equal(10, service.TrainCalls);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
});
}
// ─── Хелперы ────────────────────────────────────────────────────────────
// Строит DI-провайдер теста: фейк реестра + tenant-scoped хранилища по ITenantContext + реальный
// GrpcMlClient/IMlTrainClient к фейк-ml-service (как в проде: выбор gRPC-режима UseLocal=false).
// port: Порт хоста-фейка ml-service.
// tenants: Фейк реестра тенантов (системный scope прохода).
// tenantContext: Реальный контекст тенанта (AsyncLocal, как в приложении).
// storesByTenant: Хранилища обучения по тенантам (резолвятся по текущему контексту).
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
int port,
ITenantRepository tenants,
TenantContext tenantContext,
Dictionary<Guid, FakeMlLearningStore> storesByTenant)
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton(tenants);
// Транспорт ml-service создаётся сразу (как в AddDealIntegrations при UseLocal=false): env-токен уже
// выставлен харнессом MlGrpcTestHost, endpoint — эфемерный порт хоста-фейка.
services.AddSingleton(new MlGrpcConnection(new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }));
services.AddSingleton<MlStatusCache>();
services.AddScoped<GrpcMlClient>();
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
// Recorder GrpcMlClient (этап 10, T2): история событий + лимиты — фейки (TrainBatch recorder не зовёт).
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new FakeTokenUsageEventStore()));
services.AddScoped<TokenUsageRecorder>();
services.AddLogging();
return services.BuildServiceProvider();
}
// Создаёт планировщик на провайдере теста (без StartAsync — таймер 10 с не заводим).
// provider: DI-провайдер с сервисами цикла.
// Возвращает: Планировщик с NullLogger.
private static MlOutboxFlushScheduler CreateScheduler(ServiceProvider provider)
{
return new MlOutboxFlushScheduler(
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<MlOutboxFlushScheduler>.Instance);
}
// Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant).
// provider: Scope, в котором выполняется выгрузка тенанта.
// Возвращает: Guid тенанта из TenantId (формат N).
private static Guid TenantOf(IServiceProvider provider)
{
TenantId tenantId = provider.GetRequiredService<ITenantContext>().TenantId
?? throw new InvalidOperationException("Тест: выгрузка вне tenant-контекста (SetTenant не выполнен)");
return Guid.Parse(tenantId.Value);
}
// Сеет строки очереди в порядке created_at (id mle_&lt;prefix&gt;_&lt;n&gt;).
// store: Хранилище-фейк.
// count: Число строк.
// prefix: Префикс id (различает тенантов теста).
private static void SeedRows(
FakeMlLearningStore store,
int count,
string prefix)
{
for (int i = 0; i < count; i++)
{
store.SeedOutbox($"mle_{prefix}_{i:D2}", $"текст {prefix} {i}", i % 2 == 0 ? "b_col" : "spam", 1.0);
}
}
// Запись реестра тенанта (как строка public.tenants).
// id: Идентификатор тенанта.
// Возвращает: Запись тенанта со статусом active.
private static TenantRecordDto Tenant(Guid id) =>
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
}
@@ -0,0 +1,53 @@
using Deal.Api.Endpoints;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты хелпера фильтров эндпоинта GET /api/operator/audit (Task 4, Ruling 4): нормализация limit.
/// </summary>
public sealed class OperatorAuditEndpointsHelpersTests
{
[Fact]
public void NormalizeLimit_WhenNull_ReturnsDefault()
{
Assert.Equal(AuditService.DefaultQueryLimit, OperatorAuditEndpoints.NormalizeLimit(null));
}
[Fact]
public void NormalizeLimit_WhenNegativeOrZero_ReturnsMinimum()
{
Assert.Equal(1, OperatorAuditEndpoints.NormalizeLimit(0));
Assert.Equal(1, OperatorAuditEndpoints.NormalizeLimit(-10));
}
[Fact]
public void NormalizeLimit_WhenAboveMaximum_ReturnsMaximum()
{
Assert.Equal(AuditService.MaxQueryLimit, OperatorAuditEndpoints.NormalizeLimit(1000));
}
[Theory]
[InlineData(1, 1)]
[InlineData(25, 25)]
[InlineData(500, 500)]
public void NormalizeLimit_KeepsInRangeValue(int requested, int expected)
{
Assert.Equal(expected, OperatorAuditEndpoints.NormalizeLimit(requested));
}
[Theory]
[InlineData(null, 0)]
[InlineData(0, 0)]
[InlineData(-10, 0)]
[InlineData(42, 42)]
public void NormalizeOffset_NegativeOrNull_ReturnsZero(int? requested, int expected)
{
Assert.Equal(expected, OperatorAuditEndpoints.NormalizeOffset(requested));
}
}
@@ -0,0 +1,209 @@
using Deal.Api.Hosting;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Tenants;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты hosted-шага bootstrap оператора (Task 3, Ruling 1): env DEAL_OPERATOR_* → EnsureOperatorAsync
/// на старте + warning-логи при skip в prod и при частичной env-конфигурации (решение ревью Task 2).
/// </summary>
/// <remarks>
/// Сам шаг (создание/идемпотентность/нормализация) покрыт OperatorBootstrapServiceTests (Task 2); здесь
/// проверяется встраивание в старт: чтение env, dev-дефолт, пропуск в Production и тексты предупреждений.
/// Секреты (пароли) в логах не появляются — проверяется на сценарии с явными кредами.
/// </remarks>
public sealed class OperatorBootstrapHostedServiceTests
{
private const string DevelopmentEnvironmentName = "Development";
private const string ProductionEnvironmentName = "Production";
private const string CustomOperatorLogin = "root-admin";
private const string CustomOperatorPassword = "root-password";
[Fact]
public async Task Start_InDevelopment_WithoutEnvCredentials_SeedsDefaultOperator()
{
var context = CreateContext(environmentName: DevelopmentEnvironmentName);
await context.Hosted.StartAsync(CancellationToken.None);
var operatorRecord = Assert.Single(context.Store.Operators);
Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, operatorRecord.Login);
Assert.Equal("active", operatorRecord.Status);
Assert.True(context.PasswordHasher.Verify(OperatorBootstrapService.DefaultOperatorPassword, operatorRecord.PasswordHash));
Assert.Contains(context.Logs.Messages, m => m.Contains("dev-дефолт оператора"));
}
[Fact]
public async Task Start_InDevelopment_WithPartialCredentials_LogsWarningAndSeedsDefaults()
{
var context = CreateContext(
environmentName: DevelopmentEnvironmentName,
login: CustomOperatorLogin);
await context.Hosted.StartAsync(CancellationToken.None);
// Частичная env-конфигурация — warning, а не молчаливый дефолт (решение ревью Task 2).
Assert.Contains(context.Logs.Messages, m => m.Contains("неполна") && m.Contains(OperatorBootstrapService.PasswordEnvKey));
var operatorRecord = Assert.Single(context.Store.Operators);
Assert.Equal(OperatorBootstrapService.DefaultOperatorLogin, operatorRecord.Login);
}
[Fact]
public async Task Start_InProduction_WithoutEnvCredentials_SkipsWithWarning()
{
var context = CreateContext(environmentName: ProductionEnvironmentName);
await context.Hosted.StartAsync(CancellationToken.None);
Assert.Empty(context.Store.Operators);
Assert.Contains(context.Logs.Messages, m => m.Contains("пропущен"));
}
[Fact]
public async Task Start_InProduction_WithPartialCredentials_SkipsWithWarning()
{
var context = CreateContext(
environmentName: ProductionEnvironmentName,
password: CustomOperatorPassword);
await context.Hosted.StartAsync(CancellationToken.None);
// Без логина оператора не заводим даже при заданном пароле (секрет не используется и не логируется).
Assert.Empty(context.Store.Operators);
Assert.Contains(context.Logs.Messages, m => m.Contains("неполна") && m.Contains("пропущен"));
}
[Fact]
public async Task Start_InProduction_WithEnvCredentials_CreatesOperatorWithNormalizedLogin()
{
var context = CreateContext(
environmentName: ProductionEnvironmentName,
login: $" {CustomOperatorLogin} ",
password: CustomOperatorPassword);
await context.Hosted.StartAsync(CancellationToken.None);
var operatorRecord = Assert.Single(context.Store.Operators);
Assert.Equal(CustomOperatorLogin, operatorRecord.Login);
Assert.True(context.PasswordHasher.Verify(CustomOperatorPassword, operatorRecord.PasswordHash));
// Секрет в лог не пишется.
Assert.DoesNotContain(context.Logs.Messages, m => m.Contains(CustomOperatorPassword));
}
[Fact]
public async Task Start_InDevelopment_WithEnvCredentials_CreatesProvidedOperator()
{
var context = CreateContext(
environmentName: DevelopmentEnvironmentName,
login: CustomOperatorLogin,
password: CustomOperatorPassword);
await context.Hosted.StartAsync(CancellationToken.None);
// Явные env-креды перекрывают dev-дефолт operator/operator.
var operatorRecord = Assert.Single(context.Store.Operators);
Assert.Equal(CustomOperatorLogin, operatorRecord.Login);
Assert.True(context.PasswordHasher.Verify(CustomOperatorPassword, operatorRecord.PasswordHash));
Assert.DoesNotContain(context.Logs.Messages, m => m.Contains("пропущен"));
}
// Контекст теста: hosted-шаг на фейк-хранилище + конфигурация из словаря + коллектор логов.
private static Context CreateContext(
string environmentName,
string? login = null,
string? password = null)
{
var values = new Dictionary<string, string?>();
if (login is not null)
{
values[OperatorBootstrapService.LoginEnvKey] = login;
}
if (password is not null)
{
values[OperatorBootstrapService.PasswordEnvKey] = password;
}
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
var store = new FakeOperatorAuthStore();
var passwordHasher = new FakePasswordHasher();
var logger = new ListLogger();
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
// провайдер собирается с реальной регистрацией модуля поверх фейков.
var services = new ServiceCollection();
services.AddScoped<IOperatorAuthStore>(_ => store);
services.AddScoped<IPasswordHasher>(_ => passwordHasher);
services.AddScoped<OperatorBootstrapService>();
// Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания.
ServiceProvider provider = services.BuildServiceProvider();
var hosted = new OperatorBootstrapHostedService(
provider.GetRequiredService<IServiceScopeFactory>(),
configuration,
new FakeHostEnvironment(environmentName),
logger);
return new Context(hosted, store, passwordHasher, logger);
}
// Контекст теста bootstrap-шага.
private sealed record Context(
OperatorBootstrapHostedService Hosted,
FakeOperatorAuthStore Store,
FakePasswordHasher PasswordHasher,
ListLogger Logs);
// Окружение хоста с фиксированным именем (тестовый IHostEnvironment).
private sealed class FakeHostEnvironment(string environmentName) : IHostEnvironment
{
/// <inheritdoc />
public string ApplicationName { get; set; } = "Deal.Tests.Unit";
/// <inheritdoc />
public string EnvironmentName { get; set; } = environmentName;
/// <inheritdoc />
public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
/// <inheritdoc />
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
}
// Логгер-коллектор: копит отформатированные сообщения (эталон DiscoveryWorkerSchedulerTests).
private sealed class ListLogger : ILogger<OperatorBootstrapHostedService>
{
/// <summary>
/// Отформатированные сообщения лога в порядке записи.
/// </summary>
public List<string> Messages { get; } = [];
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
}
@@ -0,0 +1,38 @@
using Deal.Api.Configuration;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Юнит-тесты <see cref="OperatorCookieOptions"/>: имя операторской куки не пересекается с тенантной
/// <c>deal_session</c>, а срок жизни по умолчанию ссылается на единый источник — 12 часов (Ruling 1 этапа 7).
/// </summary>
public sealed class OperatorCookieOptionsTests
{
[Fact]
public void DefaultName_IsOperatorSessionCookie_DistinctFromTenantCookie()
{
var operatorOptions = new OperatorCookieOptions();
var tenantOptions = new CookieOptions();
Assert.Equal("deal_operator_session", operatorOptions.Name);
Assert.Equal("deal_session", tenantOptions.Name);
// Куки разные — операторскую и тенантную сессии нельзя подменить (изоляция сессий).
Assert.NotEqual(tenantOptions.Name, operatorOptions.Name);
}
[Fact]
public void DefaultHours_ReferenceOperatorAuthServiceSessionLifetime()
{
var options = new OperatorCookieOptions();
Assert.Equal(OperatorAuthService.SessionLifetimeHours, options.Hours);
Assert.Equal(12, OperatorAuthService.SessionLifetimeHours);
Assert.False(options.Secure);
}
}
@@ -0,0 +1,41 @@
using Deal.Api.Endpoints;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты хелперов операторских ручек лимитов (план Task 10): процент расхода бюджета (0..100).
/// </summary>
public sealed class OperatorLimitsEndpointsHelpersTests
{
[Theory]
[InlineData(0, 10_000_000, 0)]
[InlineData(5_000_000, 10_000_000, 50)]
[InlineData(8_000_000, 10_000_000, 80)]
[InlineData(10_000_000, 10_000_000, 100)]
[InlineData(500_000_000_000_000_000, 1_000_000_000_000_000_000, 50)]
public void CalculatePercent_ReturnsFloorPercentInRange(
long used,
long budget,
int expected)
{
Assert.Equal(expected, OperatorLimitsEndpoints.CalculatePercent(used, budget));
}
[Theory]
[InlineData(12_000_000, 10_000_000)]
[InlineData(long.MaxValue, 1)]
public void CalculatePercent_UsedAboveBudget_Returns100(long used, long budget)
{
Assert.Equal(100, OperatorLimitsEndpoints.CalculatePercent(used, budget));
}
[Theory]
[InlineData(0, 0)]
[InlineData(5, 0)]
public void CalculatePercent_ZeroBudget_Returns100(long used, long budget)
{
// Бюджет 0 = «ИИ запрещён» (Ruling 3): лимит трактуется исчерпанным — показываем 100%.
Assert.Equal(100, OperatorLimitsEndpoints.CalculatePercent(used, budget));
}
}
@@ -0,0 +1,69 @@
using Deal.Api;
using Deal.Api.Dtos;
using Deal.Api.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты PipelinePumpGate — общий воркер-гейт pump тенанта (план Task 11, Ruling 8; аналог
/// asyncio.Lock pipeline.py L3842): admin/tick и фоновый цикл не разбирают очередь одного тенанта
/// одновременно, разные тенанты не связаны.
/// </summary>
/// <remarks>
/// Семантика — как прототип pump_once L901902: занятый pump тенанта → повторный вход (TryEnter) даёт
/// false, вызывающий пропускает проход (очередь дождётся следующего срабатывания). Exit освобождает
/// гейт; гейт потокобезопасен (ConcurrentDictionary + атомарный TryAdd), параллельные входы одного
/// тенанта выигрывает ровно один.
/// </remarks>
public sealed class PipelinePumpGateTests
{
// Тенант A теста.
private static readonly Guid TenantA = Guid.NewGuid();
// Тенант B теста.
private static readonly Guid TenantB = Guid.NewGuid();
[Fact]
public void TryEnter_FirstEnterWinsSecondForSameTenantIsRejected()
{
var gate = new PipelinePumpGate();
Assert.True(gate.TryEnter(TenantA)); // первый воркер (admin/tick или фоновый цикл) вошёл
Assert.False(gate.TryEnter(TenantA)); // второй для того же тенанта пропускает проход (L901–902)
}
[Fact]
public void Exit_ReleasesGateForSameTenant()
{
var gate = new PipelinePumpGate();
Assert.True(gate.TryEnter(TenantA));
gate.Exit(TenantA);
Assert.True(gate.TryEnter(TenantA)); // после finally-освобождения гейт снова свободен
}
[Fact]
public void TryEnter_DifferentTenantsEnterIndependently()
{
var gate = new PipelinePumpGate();
Assert.True(gate.TryEnter(TenantA));
Assert.True(gate.TryEnter(TenantB)); // pump разных тенантов не конкурирует (свои схемы/очереди)
gate.Exit(TenantA);
Assert.True(gate.TryEnter(TenantA)); // освобождение A не влияет на занятый B
Assert.False(gate.TryEnter(TenantB));
}
[Fact]
public void Exit_WithoutEnterDoesNotBreakLaterEntry()
{
var gate = new PipelinePumpGate();
// Exit без парного TryEnter не роняет состояние (нет «висящего» флага после сбоя до входа).
gate.Exit(TenantA);
Assert.True(gate.TryEnter(TenantA));
}
}
@@ -0,0 +1,127 @@
using Deal.Api.Observability;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Data;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Extensions;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Registrars;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Registrars;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Extensions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Registrars;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Contracts;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты сборщика глубин очередей/сессий (§10.2): агрегат по тенантам через существующие сервисы.
/// </summary>
/// <remarks>
/// DealDbContext в хосте не регистрируется: секция активных сессий ловит сбой и отдаёт 0 — так тест
/// фокусируется на агрегации очередей пайплайна (PipelineProcessingService на FakePipelineStore) и
/// MlOutbox (FakeMlLearningStore) по двум тенантам, не поднимая Postgres.
/// </remarks>
public sealed class RuntimeDepthsCollectorTests
{
private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
[Fact]
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
{
var pipelineByTenant = new Dictionary<string, FakePipelineStore>
{
[TenantA.ToString("N")] = new FakePipelineStore(),
[TenantB.ToString("N")] = new FakePipelineStore(),
};
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1"));
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1"));
var outboxByTenant = new Dictionary<string, FakeMlLearningStore>
{
[TenantA.ToString("N")] = new FakeMlLearningStore(),
[TenantB.ToString("N")] = new FakeMlLearningStore(),
};
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
RuntimeDepthsCollector collector = Build(pipelineByTenant, outboxByTenant);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
Assert.Equal(3, depths.PipelineQueue);
Assert.Equal(1, depths.MlOutbox);
Assert.Equal(0, depths.ActiveSessions);
}
[Fact]
public async Task CollectAsync_NoTenants_ReturnsZeros()
{
RuntimeDepthsCollector collector = Build(
new Dictionary<string, FakePipelineStore>(),
new Dictionary<string, FakeMlLearningStore>(),
tenants: Array.Empty<TenantRecordDto>());
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
Assert.Equal(0, depths.PipelineQueue);
Assert.Equal(0, depths.MlOutbox);
Assert.Equal(0, depths.ActiveSessions);
}
private static QueueItemDto QueueItem(string id) => new()
{
Id = id,
DialogId = "d_1",
Text = "текст",
Status = PipelineQueueStatuses.New,
};
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
private static RuntimeDepthsCollector Build(
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
IReadOnlyList<TenantRecordDto>? tenants = null)
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext, TenantContext>();
services.AddSingleton<ITenantRepository>(new FakeTenantRepository(
(tenants ?? new[]
{
new TenantRecordDto(TenantA, "A", "active", DateTimeOffset.UtcNow),
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
}).ToArray()));
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)]);
services.AddScoped<IMlClient>(_ => new FakeMlClient());
services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
ServiceProvider provider = services.BuildServiceProvider();
return new RuntimeDepthsCollector(
provider.GetRequiredService<IServiceScopeFactory>(),
NullLogger<RuntimeDepthsCollector>.Instance);
}
// Текущий tenant-id контекста (формат N — как схема tenant_&lt;N&gt;).
private static string CurrentTenant(IServiceProvider provider)
{
ITenantContext context = provider.GetRequiredService<ITenantContext>();
return context.TenantId!.Value.Value;
}
}
@@ -0,0 +1,62 @@
using Deal.Api.Endpoints;
using Grpc.Core;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты чистых хелперов эндпоинтов /api/tg (план Task 14): RU-маппинг типа диалога на границе
/// (заметка Task 1, api-map §4.8 L349) и текст причины ошибки гейта для {detail} (Ruling 7/8).
/// </summary>
public sealed class TelegramEndpointsMappingTests
{
/// <summary>
/// EN-канон каталога → русская подпись вкладки «Каналы» (channel/group/forum/chat, python L461466).
/// </summary>
[Theory]
[InlineData("channel", "канал")]
[InlineData("group", "группа")]
[InlineData("forum", "группа")] // форум отображается как группа (python: мегагруппа-форум → «группа»)
[InlineData("chat", "чат")]
public void ToRussianDialogType_MapsEnCanonToRussianLabels(string kind, string expected)
{
Assert.Equal(expected, TelegramEndpoints.ToRussianDialogType(kind));
}
/// <summary>
/// Неизвестная подпись (например, уже русская из discovery-вступлений) проходит как есть.
/// </summary>
[Theory]
[InlineData("канал")]
[InlineData("")]
[InlineData("supergroup")]
public void ToRussianDialogType_UnknownKind_PassesThrough(string kind)
{
Assert.Equal(kind, TelegramEndpoints.ToRussianDialogType(kind));
}
/// <summary>
/// Доменная RPC-ошибка отдаёт канонический detail telegram-service (текст причины 1:1).
/// </summary>
[Fact]
public void GatewayErrorText_RpcDomainError_ReturnsServiceDetail()
{
var rpc = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён"));
Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc));
}
/// <summary>
/// Транспортный сбой/неизвестное исключение → «Telegram не подключён» (Ruling 7).
/// </summary>
[Theory]
[InlineData(StatusCode.Unavailable)] // сервис недоступен (detail пуст)
[InlineData(StatusCode.Unknown)]
public void GatewayErrorText_TransportFailure_ReturnsNotConnected(StatusCode statusCode)
{
var rpc = new RpcException(new Status(statusCode, string.Empty));
Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc));
Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(new InvalidOperationException("boom")));
}
}
@@ -0,0 +1,177 @@
using Deal.Api.Telegram;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты сервиса глобальных ключей Telegram (ТЗ §4.1/§8.1): шифрование/маскирование, валидация,
/// чтение расшифрованного снимка ядром.
/// </summary>
/// <remarks>
/// Прогон на фейках (FakeGlobalSettingsStore + FakeSecretCipher): проверяется, что apiHash уходит в
/// хранилище только в <c>enc:</c>-форме, наружу отдаётся маска, а apiId — открыт (не секрет).
/// </remarks>
public sealed class TelegramKeysServiceTests
{
private readonly FakeGlobalSettingsStore _store = new();
private readonly FakeSecretCipher _cipher = new();
private readonly TelegramKeysService _service;
public TelegramKeysServiceTests()
{
_service = new TelegramKeysService(_store, _cipher);
}
[Fact]
public async Task GetAsync_NoStoredKeys_ReturnsEmptySnapshot()
{
TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None);
Assert.Equal(string.Empty, snapshot.ApiId);
Assert.Equal(string.Empty, snapshot.ApiHash);
Assert.False(snapshot.KeysSet);
}
[Fact]
public async Task SaveAsync_EncryptsHashAndStoresApiIdPlain()
{
await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None);
string storedJson = _store.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!;
Assert.NotNull(storedJson);
Assert.Contains("enc:", storedJson);
Assert.DoesNotContain("abcdefghijklmnop", storedJson);
Assert.Contains("\"apiId\":\"1234567\"", storedJson);
}
[Fact]
public async Task GetAsync_AfterSave_ReturnsDecryptedSnapshot()
{
await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None);
TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None);
Assert.Equal("1234567", snapshot.ApiId);
Assert.Equal("abcdefghijklmnop", snapshot.ApiHash);
Assert.True(snapshot.KeysSet);
}
[Fact]
public async Task GetMaskedAsync_AfterSave_ReturnsApiIdAndMaskedHash()
{
await _service.SaveAsync("1234567", "abcdefghijklmnop", CancellationToken.None);
TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None);
Assert.Equal("1234567", masked.ApiId); // apiId — не секрет, отдаётся открытым
Assert.Equal("abcd…mnop", masked.ApiHash);
Assert.True(masked.KeysSet);
}
[Fact]
public async Task GetMaskedAsync_NoKeys_ReturnsEmptyMaskedForm()
{
TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None);
Assert.Equal(string.Empty, masked.ApiId);
Assert.Equal(string.Empty, masked.ApiHash);
Assert.False(masked.KeysSet);
}
[Fact]
public async Task GetMaskedAsync_ShortSecret_IsFullyMasked()
{
await _service.SaveAsync("12345", "short", CancellationToken.None);
TelegramKeysMaskedDto masked = await _service.GetMaskedAsync(CancellationToken.None);
Assert.Equal("s…", masked.ApiHash);
Assert.True(masked.KeysSet);
}
[Fact]
public async Task GetAsync_MalformedStoredJson_ReturnsEmptySnapshot()
{
_store.Preload(GlobalSettingsKeys.TelegramKeys, "not-json");
TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None);
Assert.False(snapshot.KeysSet);
}
[Fact]
public async Task GetAsync_BrokenCiphertext_ReturnsEmptyHash()
{
_store.Preload(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\",\"apiHash\":\"enc:не-base64!\"}");
TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None);
Assert.Equal("1234567", snapshot.ApiId);
Assert.Equal(string.Empty, snapshot.ApiHash);
Assert.False(snapshot.KeysSet);
}
[Theory]
[InlineData("12345")] // нижняя граница
[InlineData("123456789")] // верхняя граница
public void IsValidApiId_AcceptsFiveToNineDigits(string apiId)
{
Assert.True(TelegramKeysService.IsValidApiId(apiId));
}
[Theory]
[InlineData("")]
[InlineData("1234")] // короче 5
[InlineData("1234567890")] // длиннее 9
[InlineData("12a45")]
public void IsValidApiId_RejectsInvalidValues(string apiId)
{
Assert.False(TelegramKeysService.IsValidApiId(apiId));
}
[Theory]
[InlineData("abcdefghijklmnop")]
[InlineData("short")]
public void IsValidApiHash_AcceptsNonEmptyPlain(string apiHash)
{
Assert.True(TelegramKeysService.IsValidApiHash(apiHash));
}
[Theory]
[InlineData("")]
[InlineData("abcd…mnop")] // маска из public-снимка
[InlineData("enc:abcdefghijklmnop")] // двойное шифрование
public void IsValidApiHash_RejectsEmptyMaskAndEncrypted(string apiHash)
{
Assert.False(TelegramKeysService.IsValidApiHash(apiHash));
}
[Fact]
public async Task SaveAsync_InvalidApiId_Throws()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.SaveAsync("abc", "abcdefghijklmnop", CancellationToken.None));
}
[Fact]
public async Task SaveAsync_EmptyApiHash_Throws()
{
await Assert.ThrowsAsync<ArgumentException>(
() => _service.SaveAsync("1234567", string.Empty, CancellationToken.None));
}
[Fact]
public async Task SaveAsync_TrimsWhitespaceBeforeValidationAndStorage()
{
await _service.SaveAsync(" 1234567 ", " abcdefghijklmnop ", CancellationToken.None);
TgKeysSnapshot snapshot = await _service.GetAsync(CancellationToken.None);
Assert.Equal("1234567", snapshot.ApiId);
Assert.Equal("abcdefghijklmnop", snapshot.ApiHash);
}
}
@@ -0,0 +1,150 @@
using Deal.Api.Telegram;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Registrars;
using Deal.Modules.Settings.Application.Services;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Telegram.Application.Models;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты сборки статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9; план Task 14).
/// </summary>
/// <remarks>
/// Сервис чистый: live-поля из FakeTelegramGateway (idle по умолчанию / настраиваемый Status / падение
/// StatusFailure — «сервис недоступен → idle-форма»), account из KV tgAccount (FakeSettingsStore), monitored =
/// count мониторящихся строк FakeTelegramStore, keysSet — по глобальным ключам telegramKeys (FakeGlobalSettingsStore,
/// apiHash шифруется FakeSecretCipher). Сценарии 1:1 с status() python L103119 и формой §4.9.
/// </remarks>
public sealed class TgStatusServiceTests
{
/// <summary>
/// Гейт по умолчанию (idle/не подключён) и пустые KV/каталог → idle-форма §4.9 целиком.
/// </summary>
[Fact]
public async Task GetAsync_GatewayIdleAndEmptyTenant_ReturnsIdleForm()
{
(TgStatusService service, _, _, _, _, _) = Create();
TgStatusDto status = await service.GetAsync(CancellationToken.None);
Assert.Equal("idle", status.Phase);
Assert.False(status.Connected);
Assert.False(status.Listener);
Assert.Equal(string.Empty, status.Account);
Assert.Equal(0, status.Monitored);
Assert.False(status.KeysSet);
Assert.Null(status.Error);
Assert.Null(status.QrUrl);
}
/// <summary>
/// Сервис недоступен (RPC-отказ) → idle live-поля, но account/счётчик/keysSet ядро докладывает само.
/// </summary>
[Fact]
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys()
{
(TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, FakeTelegramGateway gateway, _, FakeGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
settings.Preload(SettingsKeys.TgAccount, "\"@user\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
gateway.StatusFailure = new RpcException(new Status(StatusCode.Unavailable, "Telegram не подключён"));
TgStatusDto status = await service.GetAsync(CancellationToken.None);
Assert.Equal("idle", status.Phase);
Assert.False(status.Connected);
Assert.False(status.Listener);
Assert.Equal("@user", status.Account);
Assert.Equal(2, status.Monitored);
Assert.True(status.KeysSet);
Assert.Null(status.Error);
Assert.Null(status.QrUrl);
}
/// <summary>
/// Готовый аккаунт: live-поля гейта, account — из KV (источник истины, не поле гейта), счётчики/ключи.
/// </summary>
[Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
{
(TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, FakeTelegramGateway gateway, _, FakeGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
gateway.Status = new TelegramAccountStatusDto(
Phase: "ready", Connected: true, Listener: true, Account: "gateway-account", Error: null, QrUrl: null);
TgStatusDto status = await service.GetAsync(CancellationToken.None);
Assert.Equal("ready", status.Phase);
Assert.True(status.Connected);
Assert.True(status.Listener);
Assert.Equal("@realuser", status.Account); // KV tgAccount перекрывает справочное поле гейта (Ruling 8)
Assert.Equal(1, status.Monitored);
Assert.True(status.KeysSet);
}
/// <summary>
/// Фаза qr несёт qrUrl из гейта (фронт рисует QR/поллит статус до ready, store.js tg-флоу).
/// </summary>
[Fact]
public async Task GetAsync_QrPhase_ReturnsQrUrlFromGateway()
{
(TgStatusService service, _, _, FakeTelegramGateway gateway, _, _) = Create();
gateway.Status = new TelegramAccountStatusDto(
Phase: "qr", Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: "https://t.me/qr/test");
TgStatusDto status = await service.GetAsync(CancellationToken.None);
Assert.Equal("qr", status.Phase);
Assert.Equal("https://t.me/qr/test", status.QrUrl);
Assert.False(status.Connected);
}
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + FakeSecretCipher).
private static (TgStatusService Service, FakeTelegramStore Store, FakeSettingsStore Settings, FakeTelegramGateway Gateway, FakeSecretCipher Cipher, FakeGlobalSettingsStore GlobalSettings) Create()
{
var store = new FakeTelegramStore();
var settings = new FakeSettingsStore();
var gateway = new FakeTelegramGateway();
var cipher = new FakeSecretCipher();
var globalSettings = new FakeGlobalSettingsStore();
var keys = new TelegramKeysService(globalSettings, cipher);
var dialogs = new DialogsService(store, settings, gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway, dialogs, settings, keys);
return (service, store, settings, gateway, cipher, globalSettings);
}
// Кладёт глобальную настройку telegramKeys (apiHash шифруется — enc: через шифр сценария).
// settings: Глобальное KV-хранилище сценария.
// apiId: apiId приложения (5..9 цифр).
// apiHash: Открытый api_hash (ядро шифрует при сохранении).
private static void PreloadKeys(
FakeGlobalSettingsStore settings,
string apiId,
string apiHash)
{
string encrypted = new FakeSecretCipher().Encrypt(apiHash);
settings.Preload(GlobalSettingsKeys.TelegramKeys, $"{{\"apiId\":\"{apiId}\",\"apiHash\":\"{encrypted}\"}}");
}
// Строка каталога минимально (id/имя/kind + флаг мониторинга).
private static FakeTelegramDialogRow Dialog(
string id,
string name,
string kind,
bool Monitor)
=> new(id, name, Handle: string.Empty, kind, Hue: "#666", Monitor, LastText: string.Empty, LastAt: null, Backfilled: false, UpdatedAt: DateTimeOffset.UtcNow);
}