Сгруппировать тестовые проекты по областям
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:
@@ -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 L111–128).
|
||||
/// </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 L117–120).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CleanKeywords_TrimsAndDropsEmpty()
|
||||
{
|
||||
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(
|
||||
[" кл юч ", " ", string.Empty, "тест"]);
|
||||
|
||||
Assert.Equal(["кл юч", "тест"], keywords);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ключ длиннее 60 символов отбрасывается (python L120: len(keyword) > _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 L121–126).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CleanKeywords_DeduplicatesCaseInsensitiveKeepingFirst()
|
||||
{
|
||||
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(["КЛЮЧ", "ключ", "Другой", "дРУгОй"]);
|
||||
|
||||
Assert.Equal(["КЛЮЧ", "Другой"], keywords);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Страховочный потолок — 30 ключей (python L127–128: 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<DateTimeOffset>: границы окна тестируются на фиксированном «сейчас» без ожидания.
|
||||
/// </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 L56–82): обход всех тенантов реестра, в собственном 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_<prefix>_<n>).
|
||||
// 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 L38–42): admin/tick и фоновый цикл не разбирают очередь одного тенанта
|
||||
/// одновременно, разные тенанты не связаны.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Семантика — как прототип pump_once L901–902: занятый 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_<N>).
|
||||
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 L461–466).
|
||||
/// </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 L103–119 и формой §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);
|
||||
}
|
||||
Reference in New Issue
Block a user