Deal — единая кодовая база
ci / build-test (push) Canceled after 0s

SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/
Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue,
контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер),
Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог).

Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0,
тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-11 23:56:47 +03:00
commit 27c7831910
1383 changed files with 158436 additions and 0 deletions
@@ -0,0 +1,105 @@
using Deal.Api.Configuration;
using Deal.Api.Hosting;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты логики прохода <see cref="DataRetentionScheduler"/>
/// </summary>
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,67 @@
using Deal.Api.Endpoints;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты чистых хелперов эндпоинтов /api/discovery
/// </summary>
public sealed class DiscoveryEndpointsHelpersTests
{
/// <summary>
/// Null/пустой ответ — пустой список.
/// </summary>
[Fact]
public void CleanKeywords_NullOrEmpty_ReturnsEmptyList()
{
Assert.Empty(DiscoveryEndpoints.CleanKeywords(null));
Assert.Empty(DiscoveryEndpoints.CleanKeywords([]));
}
/// <summary>
/// Строки тримятся; пустые/пробельные и не-строки отбрасываются.
/// </summary>
[Fact]
public void CleanKeywords_TrimsAndDropsEmpty()
{
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(
[" кл юч ", " ", string.Empty, "тест"]);
Assert.Equal(["кл юч", "тест"], keywords);
}
[Fact]
public void CleanKeywords_DropsOverlongKeyword()
{
string tooLong = new('д', 61);
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(["норм", tooLong, "ещё"]);
Assert.Equal(["норм", "ещё"], keywords);
}
/// <summary>
/// Повторы (casefold) схлопываются, сохраняется первое вхождение.
/// </summary>
[Fact]
public void CleanKeywords_DeduplicatesCaseInsensitiveKeepingFirst()
{
IReadOnlyList<string> keywords = DiscoveryEndpoints.CleanKeywords(["КЛЮЧ", "ключ", "Другой", "дРУгОй"]);
Assert.Equal(["КЛЮЧ", "Другой"], keywords);
}
/// <summary>
/// Страховочный потолок — 30 ключей.
/// </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,208 @@
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.Models;
using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Discovery;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты DiscoveryWorkerScheduler — фоновый цикл воркера Discovery
/// </summary>
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,161 @@
using Deal.Api.Extensions;
using Deal.Api.Middleware;
using Deal.Api.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Юнит-тесты <see cref="HttpAccessLogMiddleware"/>
/// </summary>
public sealed class HttpAccessLogMiddlewareTests
{
private static readonly Guid TenantId = Guid.Parse("11112222-3333-4444-5555-666677778888");
[Fact]
public async Task InvokeAsync_UserInItems_LogsActorLoginAndTenantId()
{
RecordingLogger logger = new();
var context = new DefaultHttpContext();
context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active"));
HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger);
await middleware.InvokeAsync(context);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Information, entry.Level);
Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message);
}
[Fact]
public async Task InvokeAsync_OperatorInItems_LogsActorLoginAndDashTenant()
{
RecordingLogger logger = new();
var context = new DefaultHttpContext();
context.SetCurrentOperator(new CurrentOperator(Guid.NewGuid(), "operator.login", "active"));
HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger);
await middleware.InvokeAsync(context);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Information, entry.Level);
Assert.Contains("actor=operator.login tenant=-", entry.Message);
}
[Fact]
public async Task InvokeAsync_NoSession_LogsDashActorAndTenant()
{
RecordingLogger logger = new();
var context = new DefaultHttpContext();
HttpAccessLogMiddleware middleware = new(_ => Task.CompletedTask, logger);
await middleware.InvokeAsync(context);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Information, entry.Level);
Assert.Contains("actor=- tenant=-", entry.Message);
}
[Fact]
public async Task InvokeAsync_ResponseStatusCode_IsLogged()
{
RecordingLogger logger = new();
var context = new DefaultHttpContext();
HttpAccessLogMiddleware middleware = new(
ctx =>
{
ctx.Response.StatusCode = StatusCodes.Status204NoContent;
return Task.CompletedTask;
},
logger);
await middleware.InvokeAsync(context);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Contains("-> 204", entry.Message);
}
[Fact]
public async Task InvokeAsync_NextThrows_LogsErrorWithActorAndRethrows()
{
RecordingLogger logger = new();
var context = new DefaultHttpContext();
context.SetCurrentUser(new CurrentUser(Guid.NewGuid(), "user.login", TenantId, "active"));
InvalidOperationException failure = new("ошибка конвейера");
HttpAccessLogMiddleware middleware = new(_ => throw failure, logger);
InvalidOperationException thrown = await Assert.ThrowsAsync<InvalidOperationException>(
() => middleware.InvokeAsync(context));
Assert.Same(failure, thrown);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Error, entry.Level);
Assert.Same(failure, entry.Exception);
Assert.Contains($"actor=user.login tenant={TenantId:N}", entry.Message);
}
[Fact]
public async Task InvokeAsync_GrpcContentType_SkipsLogging()
{
RecordingLogger logger = new();
var context = new DefaultHttpContext();
context.Request.ContentType = "application/grpc";
bool nextCalled = false;
HttpAccessLogMiddleware middleware = new(
_ =>
{
nextCalled = true;
return Task.CompletedTask;
},
logger);
await middleware.InvokeAsync(context);
Assert.True(nextCalled);
Assert.Empty(logger.Entries);
}
// In-memory логгер: копит записи access-лога с уровнем, сообщением, исключением и полями.
private sealed class RecordingLogger : ILogger<HttpAccessLogMiddleware>
{
/// <summary>
/// Записи лога в порядке поступления.
/// </summary>
public List<LogEntry> Entries { 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)
{
Dictionary<string, object?> fields = new(StringComparer.Ordinal);
if (state is IEnumerable<KeyValuePair<string, object?>> pairs)
{
foreach ((string key, object? value) in pairs)
{
fields[key] = value;
}
}
Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception, fields));
}
}
// Запись access-лога, снятая in-memory логгером.
private sealed record LogEntry(
LogLevel Level,
string Message,
Exception? Exception,
IReadOnlyDictionary<string, object?> Fields);
}
@@ -0,0 +1,158 @@
using Deal.Api.Configuration;
using Deal.Api.Middleware;
using Deal.Grpc.Sources;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
using Grpc.Core;
using Grpc.Health.V1;
using Grpc.Net.Client;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// In-proc gRPC-тесты IngressRateLimitInterceptor
/// </summary>
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 диалога-оригинала сценариев PushSource.
private const string DialogId = "d_channel_rate_limit";
// Окно сценария по умолчанию: 2/мин — быстрый тест без ожидания реального окна.
private const int DefaultPermitsPerMinute = 2;
// Окно health-сценария: 1/мин — единственный вызов исчерпывает лимит.
private const int HealthScenarioPermitsPerMinute = 1;
// ─── Окно интерцептора ─────────────────────────────────────────────────
/// <summary>
/// 3-й вызов тенанта в минуту
/// </summary>
[Fact]
public async Task PushSource_ExceedingTenantWindow_ThirdRejectedOtherTenantPasses()
{
var registry = new FakeTenantRegistry(Tenant(TenantA), Tenant(TenantB));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
async channel =>
{
PushSourceReply first = await PushAsync(channel, TenantA);
PushSourceReply 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 — отдельная партиция: вызов проходит.
PushSourceReply tenantB = await PushAsync(channel, TenantB);
Assert.True(tenantB.Accepted);
});
}
/// <summary>
/// Health (grpc.health.v1) освобождён от лимита
/// </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/мин: единственный разрешённый вызов исчерпывает лимит.
PushSourceReply push = await PushAsync(channel, TenantA);
Assert.True(push.Accepted);
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,
});
// Вызывает PushSource с metadata сценария (service-token + tenant-id, deadline 10 с).
// channel: Канал к хосту.
// tenantId: Id тенанта в metadata.
private static Task<PushSourceReply> PushAsync(GrpcChannel channel, Guid tenantId)
{
var client = new SourceIngressService.SourceIngressServiceClient(channel);
var request = new PushSourceRequest
{
Source = new SourceRefProto
{
Kind = "telegram",
OriginRef = DialogId,
ExternalId = "1",
DisplayName = "Канал",
},
Content = new SourceContentProto { Text = "Сообщение сценария лимита" },
};
request.Source.Extra["hue"] = "#a33";
AsyncUnaryCall<PushSourceReply> call = client.PushSourceAsync(
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,29 @@
using Deal.Api.Services;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты InlineProgress — синхронный IProgress.
/// </summary>
public sealed class InlineProgressTests
{
[Fact]
public void Report_InvokesHandlerSynchronouslyWithValue()
{
int callerThread = Environment.CurrentManagedThreadId;
int handlerThread = -1;
ReclassifyProgressDto? seen = null;
var progress = new InlineProgress<ReclassifyProgressDto>(value =>
{
handlerThread = Environment.CurrentManagedThreadId;
seen = value;
});
var expected = new ReclassifyProgressDto(5, 12, 3, 2, 0, 0);
progress.Report(expected);
Assert.Equal(expected, seen);
Assert.Equal(callerThread, handlerThread);
}
}
@@ -0,0 +1,204 @@
using Deal.Api.Configuration;
using Deal.Api.Services;
using Deal.Tests.Unit.Modules.Tenants;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Unit-тесты LoginAttemptGuard
/// </summary>
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 неудач подряд в окне — следующий вход ключа блокирован.
/// </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));
}
/// <summary>
/// Успешный вход сбрасывает счётчик
/// </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
/// </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>
/// Пустой/пробельный логин ключа не имеет — блокировке не подлежит
/// </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,214 @@
using Deal.Api.Hosting;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты MlOutboxFlushScheduler — логика прохода цикла выгрузки обучения ML
/// </summary>
[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);
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());
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,48 @@
using Deal.Api.Endpoints;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты хелпера фильтров эндпоинта GET /api/operator/audit
/// </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,199 @@
using Deal.Api.Hosting;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Modules.Tenants;
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;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты hosted-шага bootstrap оператора
/// </summary>
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);
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 = TestHashers.New();
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,
IPasswordHasher 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,32 @@
using Deal.Api.Configuration;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Юнит-тесты <see cref="OperatorCookieOptions"/>
/// </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,39 @@
using Deal.Api.Endpoints;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты хелперов операторских ручек лимитов
/// </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)
{
Assert.Equal(100, OperatorLimitsEndpoints.CalculatePercent(used, budget));
}
}
@@ -0,0 +1,58 @@
using Deal.Api.Services;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты PipelinePumpGate — общий воркер-гейт pump тенанта
/// </summary>
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,174 @@
using Deal.Api.Observability;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure.Data;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты сборщика глубин очередей/сессий
/// </summary>
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");
private static readonly Guid TenantC = Guid.Parse("33333333-3333-3333-3333-333333333333");
[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);
Assert.NotNull(depths.Budgets);
Assert.Equal(2, depths.Budgets!.Count);
}
[Fact]
public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
{
var limits = new FakeTenantLimitStore();
DateTimeOffset periodStart = DateTimeOffset.UtcNow;
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
limits.Preload(TenantC, 100, TenantLimitPeriods.Month, periodStart, 150);
RuntimeDepthsCollector collector = Build(
PipelineStores(TenantA, TenantB, TenantC),
OutboxStores(TenantA, TenantB, TenantC),
TenantRecords(TenantA, TenantB, TenantC),
limits);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
Assert.NotNull(depths.Budgets);
Assert.Equal(3, depths.Budgets!.Count);
Assert.Equal(0.8, RatioFor(depths.Budgets, TenantA), 3);
Assert.Equal(0.0, RatioFor(depths.Budgets, TenantB), 3);
Assert.Equal(1.0, RatioFor(depths.Budgets, TenantC), 3);
}
[Fact]
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
{
var limits = new FakeTenantLimitStore();
limits.FailStateReads.Add(TenantA);
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
RuntimeDepthsCollector collector = Build(
PipelineStores(TenantA, TenantB),
OutboxStores(TenantA, TenantB),
TenantRecords(TenantA, TenantB),
limits);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
Assert.NotNull(depths.Budgets);
TenantBudgetUsageDto budget = Assert.Single(depths.Budgets!);
Assert.Equal(TenantB.ToString("N"), budget.TenantId);
Assert.Equal(0.8, budget.UsedRatio, 3);
}
[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,
Text = "текст",
Status = PipelineQueueStatuses.New,
};
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
=> tenants
.Select((tenant, index) => new TenantRecordDto(tenant, $"T{index}", "active", DateTimeOffset.UtcNow))
.ToArray();
private static double RatioFor(IReadOnlyList<TenantBudgetUsageDto> budgets, Guid tenant)
=> budgets.Single(budget => budget.TenantId == tenant.ToString("N")).UsedRatio;
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
private static RuntimeDepthsCollector Build(
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
IReadOnlyList<TenantRecordDto>? tenants = null,
ITenantLimitStore? limitStore = 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)]);
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore());
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,111 @@
using Deal.Api.Services;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Unit-тесты учёта подозрительной активности
/// </summary>
public sealed class SuspiciousActivityReporterTests
{
// Актор сценариев.
private const string Actor = "tenant-1";
/// <summary>
/// Report инкрементит deal.security.suspicious с меткой kind
/// </summary>
[Fact]
public void Report_IncrementsSecurityCounter_WithKindTag()
{
using var capture = new SecurityEventCapture();
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
reporter.Report(SuspiciousActivityReporter.RateLimitKind, Actor);
Assert.Equal(1, capture.Sum(SuspiciousActivityReporter.RateLimitKind));
(string? Kind, long Value) measurement = Assert.Single(
capture.Snapshot(),
item => item.Kind == SuspiciousActivityReporter.RateLimitKind);
Assert.Equal(1, measurement.Value);
}
/// <summary>
/// Report пишет предупреждение с видом и актором
/// </summary>
[Fact]
public void Report_WritesWarningWithKindAndActor()
{
var logger = new RecordingLogger();
var reporter = new SuspiciousActivityReporter(logger);
reporter.Report(SuspiciousActivityReporter.LoginBlockedKind, Actor);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Warning, entry.Level);
Assert.Contains(SuspiciousActivityReporter.LoginBlockedKind, entry.Message);
Assert.Contains($"actor={Actor}", entry.Message);
}
/// <summary>
/// null/пусто/пробелы actor → в логе «-»
/// </summary>
/// <param name="actor">Пустой актор сценария.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Report_UnknownActor_WritesDash(string? actor)
{
var logger = new RecordingLogger();
var reporter = new SuspiciousActivityReporter(logger);
reporter.Report(SuspiciousActivityReporter.RateLimitKind, actor);
LogEntry entry = Assert.Single(logger.Entries);
Assert.Contains("actor=-", entry.Message);
}
/// <summary>
/// Пустой/пробельный kind → ArgumentException
/// </summary>
/// <param name="kind">Пустой вид события сценария.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Report_EmptyOrWhitespaceKind_Throws(string? kind)
{
var reporter = new SuspiciousActivityReporter(new RecordingLogger());
Assert.ThrowsAny<ArgumentException>(() => reporter.Report(kind!, Actor));
}
// In-memory логгер: копит записи с уровнем, сообщением и исключением.
private sealed class RecordingLogger : ILogger<SuspiciousActivityReporter>
{
/// <summary>
/// Записи лога в порядке поступления.
/// </summary>
public List<LogEntry> Entries { 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)
=> Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception));
}
// Запись лога, снятая in-memory логгером.
private sealed record LogEntry(LogLevel Level, string Message, Exception? Exception);
}
@@ -0,0 +1,60 @@
using Deal.Api.Endpoints;
using Grpc.Core;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты чистых хелперов эндпоинтов /api/tg
/// </summary>
public sealed class TelegramEndpointsMappingTests
{
/// <summary>
/// EN-канон каталога → русская подпись вкладки «Каналы».
/// </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>
/// Неизвестная подпись
/// </summary>
[Theory]
[InlineData("канал")]
[InlineData("")]
[InlineData("supergroup")]
public void ToRussianDialogType_UnknownKind_PassesThrough(string kind)
{
Assert.Equal(kind, TelegramEndpoints.ToRussianDialogType(kind));
}
/// <summary>
/// Доменная RPC-ошибка отдаёт канонический detail telegram-service.
/// </summary>
[Fact]
public void GatewayErrorText_RpcDomainError_ReturnsServiceDetail()
{
var rpc = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён"));
Assert.Equal("Telegram не подключён", TelegramEndpoints.GatewayErrorText(rpc));
}
/// <summary>
/// Транспортный сбой/неизвестное исключение → «Telegram не подключён».
/// </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,169 @@
using Deal.Api.Telegram;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты сервиса глобальных ключей Telegram
/// </summary>
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,141 @@
using Deal.Api.Telegram;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Telegram.Application.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты сборки статуса вкладки Telegram — GET /api/tg/status.
/// </summary>
public sealed class TgStatusServiceTests
{
/// <summary>
/// Гейт по умолчанию
/// </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>
/// Сервис недоступен
/// </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>
/// Готовый аккаунт
/// </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 из гейта
/// </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);
}
@@ -0,0 +1,176 @@
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты декоратора бюджетного гейта <see cref="BudgetedAiClassifier"/>
/// </summary>
public sealed class BudgetedAiClassifierTests
{
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита.
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
// Начало периода строк лимита (не истёкший месяц).
private static readonly DateTimeOffset PeriodStart = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero);
// Разбор «платной» ветки — маркер: если тест увидел его, вызов ушёл на платного исполнителя.
private static readonly AiParsedCardDto PaidParsedLead = new(
"Платная ветка",
Company: null,
Format: null,
Task: null,
Requirements: null,
Plus: null,
Conditions: null,
Summary: null,
Stack: new[] { "Go" },
Budget: null,
Contacts: Array.Empty<AiContactDto>(),
IsVacancy: true,
IsVacancyKnown: true,
IsSpam: false,
Board: null);
[Fact]
public async Task Classify_ZeroBudgetLimit_UsesLocalParseAndDoesNotCallPaid()
{
Context ctx = Create(
limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
ctx.Paid.ClassifyResult = PaidParsedLead;
const string text = "Вакансия: Python-разработчик в команду, оплата до 2000$ в месяц";
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
Assert.Equal(0, ctx.Paid.ClassifyCalls); // платный ИИ не зван
Assert.NotNull(result);
Assert.NotEqual(PaidParsedLead, result); // разбор собран Local-реализацией, а не платной веткой
Assert.Null(result.Board); // локальный путь не назначает смысловые колонки
}
[Fact]
public async Task Filter_ExhaustedBudget_ReturnsLocalPassSkippedAndDoesNotCallPaid()
{
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000));
ctx.Paid.FilterResult = new AiFilterResultDto(Pass: false, Reason: "реклама", Skipped: false);
AiFilterResultDto result = await ctx.Decorator.FilterAsync("Купите канал", CancellationToken.None);
Assert.Equal(0, ctx.Paid.FilterCalls);
Assert.True(result.Pass);
Assert.True(result.Skipped);
Assert.Null(result.Reason);
}
[Fact]
public async Task Classify_LargeBudgetLimit_CallsPaidAndReturnsItsResult()
{
Context ctx = Create();
ctx.Paid.ClassifyResult = PaidParsedLead;
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync("текст", CancellationToken.None);
Assert.Same(PaidParsedLead, result);
Assert.Equal(1, ctx.Paid.ClassifyCalls);
}
[Fact]
public async Task Classify_SuspendedTenant_UsesLocalParseAndDoesNotCallPaid()
{
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100,
status: TenantStatuses.Suspended));
ctx.Paid.ClassifyResult = PaidParsedLead;
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(
"Вакансия: разработчик в команду", CancellationToken.None);
Assert.Equal(0, ctx.Paid.ClassifyCalls);
Assert.NotNull(result);
Assert.NotEqual(PaidParsedLead, result);
}
[Fact]
public async Task Filter_SuspendedTenant_ReturnsLocalPassSkipped()
{
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100,
status: TenantStatuses.Suspended));
AiFilterResultDto result = await ctx.Decorator.FilterAsync("текст", CancellationToken.None);
Assert.Equal(0, ctx.Paid.FilterCalls);
Assert.True(result.Pass);
Assert.True(result.Skipped);
}
[Fact]
public async Task Classify_LocalFallback_MatchesStandaloneLocalClassifier()
{
// Гейт-запрет уводит ровно на Local-реализацию: результат совпадает с прямым вызовом LocalAiClassifier.
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings));
const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$";
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
AiParsedCardDto expected = await standaloneLocal.ClassifyAsync(text, CancellationToken.None);
Assert.Equal(0, ctx.Paid.ClassifyCalls);
// Record-равенство по списковым свойствам — ссылочное; сравниваем значимые поля разбора.
Assert.Equal(expected.Title, result.Title);
Assert.Equal(expected.Stack, result.Stack);
Assert.Equal(expected.Budget, result.Budget);
Assert.Equal(expected.IsVacancy, result.IsVacancy);
Assert.Equal(expected.Board, result.Board);
}
// ─── Контекст сценария ─────────────────────────────────────────────────
// Контекст теста: декоратор над платным фейком + реальным Local-классификатором (fallback).
// Decorator: Декоратор бюджетного гейта.
// Paid: Платный фейк-исполнитель (счётчики/ответы сценария).
// Settings: KV-настройки тенанта (маркеры LocalFieldsParser).
private sealed record Context(
IAiClassifier Decorator,
FakeAiClassifier Paid,
FakeSettingsStore Settings);
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
// (зеркало регистрации AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
{
var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiClassifier();
IAiClassifier decorator = new BudgetedAiClassifier(
paid,
new LocalAiClassifier(new LocalFieldsParser(settings)),
limits,
tenantContext,
NullLogger<BudgetedAiClassifier>.Instance);
return new Context(decorator, paid, settings);
}
}
@@ -0,0 +1,154 @@
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты декоратора бюджетного гейта <see cref="BudgetedAiTools"/>
/// </summary>
public sealed class BudgetedAiToolsTests
{
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита.
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
// Начало периода строк лимита (не истёкший месяц).
private static readonly DateTimeOffset PeriodStart = new(2026, 9, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task EvaluateFit_ExhaustedBudget_ThrowsAiUnavailableAndDoesNotCallPaid()
{
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000));
ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: true, Reason: "подходит");
AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
Assert.Contains("исчерпан", exception.Message);
}
[Fact]
public async Task EvaluateFit_ZeroBudgetLimit_ThrowsAiUnavailable()
{
Context ctx = Create(
limits => limits.Preload(TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
await Assert.ThrowsAsync<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
}
[Fact]
public async Task EvaluateFit_SuspendedTenant_ThrowsAiUnavailable()
{
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100,
status: TenantStatuses.Suspended));
AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls);
Assert.Contains("приостановлен", exception.Message);
}
[Fact]
public async Task EvaluateFit_LargeBudget_CallsPaidAndReturnsItsResult()
{
// Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов.
Context ctx = Create();
ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит");
AiEvaluateFitResultDto result = await ctx.Decorator.EvaluateFitAsync(
"текст", "описание", new[] { "ключ" }, CancellationToken.None);
Assert.False(result.Fit);
Assert.Equal(1, ctx.Paid.EvaluateFitCalls);
}
[Fact]
public async Task GenerateKeywords_ExhaustedBudget_ReturnsSoftErrorWithoutPaidCall()
{
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000));
AiGenerateKeywordsResultDto result = await ctx.Decorator.GenerateKeywordsAsync(
"описание ниши", CancellationToken.None);
Assert.False(result.Ok);
Assert.Empty(result.Keywords);
Assert.Contains("исчерпан", result.Error);
// Платный GenerateKeywords бросает NotSupportedException — тест упал бы, если бы декоратор его позвал.
}
[Fact]
public async Task GenerateKeywords_SuspendedTenant_ReturnsSoftError()
{
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 100,
status: TenantStatuses.Suspended));
AiGenerateKeywordsResultDto result = await ctx.Decorator.GenerateKeywordsAsync(
"описание ниши", CancellationToken.None);
Assert.False(result.Ok);
Assert.Contains("приостановлен", result.Error);
}
[Fact]
public async Task GenerateKeywords_LargeBudget_DelegatesToPaid()
{
// Строки лимита нет — ленивый дефолт → Allowed=true → декоратор делегирует платному исполнителю.
// FakeAiTools.GenerateKeywordsAsync всегда бросает NotSupportedException (вызов не ждали) — само
// исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный).
Context ctx = Create();
await Assert.ThrowsAsync<NotSupportedException>(
() => ctx.Decorator.GenerateKeywordsAsync("описание", CancellationToken.None));
}
// ─── Контекст сценария ─────────────────────────────────────────────────
// Контекст теста: декоратор над платным фейком инструментов и фейком лимитов.
// Decorator: Декоратор бюджетного гейта.
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
private sealed record Context(
IAiTools Decorator,
FakeAiTools Paid);
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
// AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
{
var limits = new FakeTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiTools();
IAiTools decorator = new BudgetedAiTools(
paid,
limits,
tenantContext,
NullLogger<BudgetedAiTools>.Instance);
return new Context(decorator, paid);
}
}
@@ -0,0 +1,308 @@
using System.Globalization;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты композитора карточки CardComposer.
/// </summary>
public sealed class CardComposerTests
{
// ─── Сборка полной карточки (блоки «О заявке», бюджет+conv, контакты, источник) ───────
[Fact]
public async Task BuildAsync_FullParsedLead_ComposesCardSnapshotWithBlocksBudgetAndContacts()
{
(CardComposer composer, _, _) = Create();
AiParsedCardDto parsed = Parsed(
title: "Python-разработчик на бота для CRM",
company: "ООО Ромашка",
format: "удалённо",
task: "разработка бота для CRM",
requirements: new[] { "Python", "aiogram" },
plus: new[] { "Docker" },
conditions: "оплата по результатам собеседования",
stack: new[] { "Python", "aiogram", "SQL" },
budget: new AiBudgetDto(1500, 2000, "USD"),
contacts: new[] { new AiContactDto("tg", "@ivan_py"), new AiContactDto("email", "ivan@roma.ru") },
isVacancy: true,
isVacancyKnown: true);
QueueItemDto message = Message("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.");
CardSnapshot snapshot = await composer.BuildAsync(parsed, message, cardId: "l_test1", CancellationToken.None);
Assert.Equal("l_test1", snapshot.Id);
Assert.Equal(KanbanColumns.Inbox, snapshot.Col);
Assert.True(snapshot.IsNew);
Assert.Equal(KanbanColumns.Inbox, snapshot.PrevCol);
Assert.Empty(snapshot.MatchHits); // Ruling 2: для «Неразобранного» совпадений нет
Assert.True(snapshot.IsVacancy);
Assert.True(snapshot.IsVacancyKnown); // тип подтверждён ИИ (known=true разбора)
Assert.Equal("Python-разработчик на бота для CRM", snapshot.Title);
Assert.Equal(
"Компания: ООО Ромашка\nФормат: удалённо\nО задаче: разработка бота для CRM\n"
+ "Требования: Python, aiogram\nБудет плюсом: Docker\nУсловия: оплата по результатам собеседования",
snapshot.Summary);
Assert.Equal(new[] { "Python", "aiogram", "SQL" }, snapshot.Stack);
// Бюджет как в разборе + конверсия «один раз при поступлении» (мок-курсы: USD → RUB).
Assert.Equal(new CardBudgetDto(1500, 2000, "USD"), Budget(snapshot));
Assert.Equal(
new CardBudgetDto(1500 * MockRates.Values["USD"], 2000 * MockRates.Values["USD"], "RUB"),
Converted(snapshot));
// Контакты квалифицированы (значения разбора пере-квалифицированы), contact — основной (tg).
Assert.Equal(
new[] { new CardContactDto("tg", "@ivan_py"), new CardContactDto("email", "ivan@roma.ru") },
snapshot.Contacts);
Assert.Equal("@ivan_py", snapshot.Contact);
Assert.Equal("test", snapshot.Source.Kind);
Assert.Equal("d_1", snapshot.Source.OriginRef);
Assert.Equal("123", snapshot.Source.ExternalId);
Assert.Equal("Канал найма", snapshot.Source.DisplayName);
Assert.Equal("#ababab", snapshot.Source.ResolveHue());
Assert.Equal(1_700_000_000_000L, snapshot.ReceivedAt.ToUnixTimeMilliseconds());
Assert.Equal("ООО Ромашка ищет Python-разработчика на бота для CRM, удалённо.", snapshot.Content.Text);
}
[Fact]
public async Task BuildAsync_LongSourceText_TruncatesContentTextTo4000CodePoints()
{
(CardComposer composer, _, _) = Create();
string longText = new string('а', 4500);
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(title: "Заголовок"),
Message(longText),
cardId: "l_test2",
CancellationToken.None);
Assert.Equal(4000, (snapshot.Content.Text ?? string.Empty).EnumerateRunes().Count()); // python text[:4000] (L503)
}
[Fact]
public async Task BuildAsync_EmptyTitle_ReusesMessageStart()
{
(CardComposer composer, _, _) = Create();
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(),
Message("Ищем Java-разработчика\nв команду на проект"),
cardId: "l_test3",
CancellationToken.None);
Assert.Equal("Ищем Java-разработчика в команду на проект", snapshot.Title); // clean_short(text, 140)
}
[Fact]
public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(title: "Python-разработчик", board: "b_py"),
Message("Ищем Python-разработчика в команду"),
cardId: "l_test4",
CancellationToken.None);
Assert.Equal("b_py", snapshot.Col);
Assert.Empty(snapshot.MatchHits);
}
[Fact]
public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" }));
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(title: "Python-разработчик", board: "b_wpf"),
Message("Ищем Python-разработчика в команду"),
cardId: "l_test5",
CancellationToken.None);
Assert.Equal(KanbanColumns.Inbox, snapshot.Col);
Assert.Empty(snapshot.MatchHits);
}
[Fact]
public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_py", new[] { "python" }));
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(title: "Python-разработчик", board: "b_py"),
Message("Ищем Python-разработчика в команду"),
cardId: "l_test6",
CancellationToken.None);
Assert.Equal("b_py", snapshot.Col);
MatchHitDto hit = Assert.Single(snapshot.MatchHits);
Assert.Equal("Слова", hit.Label);
Assert.Equal("python", hit.Term);
}
[Fact]
public async Task BuildAsync_MissingBoard_FallsBackToInbox()
{
(CardComposer composer, _, _) = Create();
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(title: "Python-разработчик", board: "b_deleted"),
Message("Ищем Python-разработчика в команду"),
cardId: "l_test7",
CancellationToken.None);
Assert.Equal(KanbanColumns.Inbox, snapshot.Col);
Assert.Empty(snapshot.MatchHits);
}
// ─── Бюджет: fallback из текста и выключатель конверсии ───────────────────────────────
[Fact]
public async Task BuildAsync_NoBudgetInParse_UsesFirstAmountFromText()
{
(CardComposer composer, _, _) = Create();
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(title: "Лендинг для кофейни"),
Message("Заказ: лендинг для кофейни, дизайн и вёрстка. Бюджет 2 000$."),
cardId: "l_test8",
CancellationToken.None);
Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), Budget(snapshot));
Assert.Equal(
new CardBudgetDto(2000 * MockRates.Values["USD"], 2000 * MockRates.Values["USD"], "RUB"),
Converted(snapshot));
}
[Fact]
public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty()
{
(CardComposer composer, _, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348349
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(budget: new AiBudgetDto(1500, 2000, "USD")),
Message("Ищем разработчика, оплата 15002000$"),
cardId: "l_test9",
CancellationToken.None);
// Бюджет в исходной валюте сохраняется, конверсия снята (convCur = "" → пустые conv-поля).
Assert.Equal(new CardBudgetDto(1500, 2000, "USD"), Budget(snapshot));
Assert.Empty(snapshot.ConvCur);
Assert.Null(snapshot.ConvFrom);
Assert.Null(snapshot.ConvTo);
}
// ─── Контакты: fallback из текста и дефолт цвета канала ───────────────────────────────
[Fact]
public async Task BuildAsync_NoContactsInParse_ExtractsFromText()
{
(CardComposer composer, _, _) = Create();
CardSnapshot snapshot = await composer.BuildAsync(
Parsed(title: "Заказ на бота"),
Message("Нужен бот для CRM, пишите @client_py или на почту client@q.ru"),
cardId: "l_test10",
CancellationToken.None);
Assert.Equal(
new[] { new CardContactDto("tg", "@client_py"), new CardContactDto("email", "client@q.ru") },
snapshot.Contacts);
Assert.Equal("@client_py", snapshot.Contact); // primary_contact: tg важнее почты
}
// ─── Хелперы ──────────────────────────────────────────────────────────────────────────
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
return (new CardComposer(store, settings), store, settings);
}
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
private static AiParsedCardDto Parsed(
string title = "",
string? company = null,
string? format = null,
string? task = null,
IReadOnlyList<string>? requirements = null,
IReadOnlyList<string>? plus = null,
string? conditions = null,
string? summary = null,
IReadOnlyList<string>? stack = null,
AiBudgetDto? budget = null,
IReadOnlyList<AiContactDto>? contacts = null,
bool isVacancy = false,
bool isVacancyKnown = false,
bool isSpam = false,
string? board = null) => new(
title, company, format, task, requirements, plus, conditions, summary,
stack ?? Array.Empty<string>(),
budget,
contacts ?? Array.Empty<AiContactDto>(),
isVacancy, isVacancyKnown, isSpam, board);
// Строка очереди-источника со значениями по умолчанию (поля сообщения, не статус).
private static QueueItemDto Message(
string text,
string dialogId = "d_1",
long? msgId = 123,
string channelName = "Канал найма",
string channelHue = "#ababab",
long msgAtMs = 1_700_000_000_000) => SourceItemFactory.Queue(
"p_1",
text,
PipelineQueueStatuses.Filtered,
origin: dialogId,
externalId: msgId?.ToString(CultureInfo.InvariantCulture),
name: channelName,
hue: channelHue,
msgAtMs: msgAtMs,
queuedAtMs: msgAtMs);
// Доска с правилами (для сценариев ContainerAccepts-страховки).
// id: Id доски (b_...).
// keywords: Группа ключевых слов правил (режим any).
private static ContainerDto BoardWithRules(string id, IReadOnlyList<string> keywords) => new()
{
Id = id,
Rules = new ContainerRulesDto(
Mode: "any",
Direction: Array.Empty<string>(),
Keywords: keywords,
Stack: Array.Empty<string>(),
Grade: Array.Empty<string>(),
Exclude: Array.Empty<string>(),
Budget: null),
};
// Бюджет снимка (форма хранения — как CardBudgetDto карточки).
private static CardBudgetDto? Budget(CardSnapshot snapshot) => snapshot.BudgetCur.Length == 0
? null
: new CardBudgetDto(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur);
// Конверсия снимка (форма хранения — как CardBudgetDto карточки).
private static CardBudgetDto? Converted(CardSnapshot snapshot) => snapshot.ConvCur.Length == 0
? null
: new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur);
}
@@ -0,0 +1,792 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты CardsService — карточки
/// </summary>
public sealed class CardsServiceTests
{
[Fact]
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100));
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
IReadOnlyList<CardDto> cards = await service.ListCardsAsync(col: null, CancellationToken.None);
// Без фильтра — все карточки дашборда; порядок — received_at DESC.
Assert.Equal(new[] { "l_2", "l_1" }, cards.Select(card => card.Id));
}
[Fact]
public async Task ListCards_ByColumn_ReturnsOnlyColumnCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
store.SeedCard(Card("l_2", KanbanColumns.Trash));
IReadOnlyList<CardDto> cards = await service.ListCardsAsync(KanbanColumns.Trash, CancellationToken.None);
CardDto card = Assert.Single(cards);
Assert.Equal("l_2", card.Id);
}
[Fact]
public async Task GetCard_Existing_ReturnsCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python"));
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
Assert.NotNull(card);
Assert.Equal("Middle Python", card!.Title);
}
[Fact]
public async Task GetCard_Missing_ReturnsNull()
{
(CardsService service, _, _, _) = Create();
CardDto? card = await service.GetCardAsync("l_missing", CancellationToken.None);
Assert.Null(card);
}
[Fact]
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
Assert.Null(result.Error);
Assert.Equal("b_py", result.Card!.Col);
Assert.False(result.Card.IsNew); // перенос снимает «новое»
Assert.Equal("inbox", result.Card.PrevCol); // prev_col = прежняя колонка
MatchHitDto hit = Assert.Single(result.Card.MatchHits); // «почему в колонке» (Ruling 2)
Assert.Equal("Стек", hit.Label);
Assert.Equal("python", hit.Term);
}
[Fact]
public async Task Move_ToBoard_WritesJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
CardMoveDto move = Assert.Single(store.Moves); // журнал learning_log (Ruling 4)
Assert.StartsWith("lm_", move.Id);
Assert.Equal("l_1", move.LeadId);
Assert.Equal("move", move.Action);
Assert.Equal("inbox", move.FromCol);
Assert.Equal("b_py", move.ToCol);
(string text, string label, double delta) = Assert.Single(ml.Pushed); // обучение всегда (Ruling 4)
Assert.Equal("Нужен Python-разработчик", text);
Assert.Equal("b_py", label);
Assert.Equal(1.0, delta);
}
[Fact]
public async Task Move_ToInbox_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "inbox", CancellationToken.None);
Assert.Null(result.Error);
Assert.Equal("inbox", result.Card!.Col);
CardMoveDto move = Assert.Single(store.Moves);
Assert.Equal("move", move.Action);
Assert.Equal("b_py", move.FromCol);
Assert.Equal("inbox", move.ToCol);
Assert.Empty(ml.Pushed); // «Неразобранное» не учим (Ruling 4, L188)
Assert.Empty(result.Card.MatchHits); // inbox — служебная колонка, hits пусто (Ruling 2)
}
[Fact]
public async Task Move_ToUnknownBoard_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); // 400 «Переносить можно только…»
Assert.Null(result.Card);
Assert.Empty(store.Moves);
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task Move_UnknownBoardAndMissingCard_Returns400TextFirst()
{
(CardsService service, _, _, _) = Create();
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_ghost", CancellationToken.None);
Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error);
}
[Fact]
public async Task Move_CardMissing_ReturnsNullLead()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
Assert.Null(result.Error);
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(store.Moves);
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task Move_SameColumn_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
Assert.Null(result.Error);
Assert.NotNull(result.Card);
Assert.Equal("b_py", result.Card!.Col);
Assert.True(result.Card.IsNew); // флаг не тронут
Assert.Empty(store.Moves);
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_free")); // правил нет
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_free", CancellationToken.None);
Assert.Null(result.Error);
Assert.Equal("b_free", result.Card!.Col);
Assert.Empty(result.Card.MatchHits); // доска без активных правил → [] (Ruling 2)
Assert.Single(ml.Pushed);
Assert.Equal("b_free", Assert.Single(ml.Pushed).Label);
}
[Fact]
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" })));
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
Assert.Equal("b_py", result.Card!.Col);
Assert.Equal("b_java", result.Card.PrevCol);
CardMoveDto move = Assert.Single(store.Moves);
Assert.Equal("b_java", move.FromCol);
Assert.Equal("b_py", move.ToCol);
(string _, string label, double _) = Assert.Single(ml.Pushed);
Assert.Equal("b_py", label); // учим НОВУЮ колонку (L188–191)
}
[Fact]
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
Assert.Single(result.Card!.MatchHits);
(string text, string _, double _) = Assert.Single(ml.Pushed);
Assert.Equal("Middle Python-разработчик", text);
}
[Fact]
public async Task Move_EmptySourceAndTitle_JournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "inbox"));
await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
Assert.Single(store.Moves); // перенос записан
Assert.Empty(ml.Pushed); // пустой текст не учим (L190191: if text)
}
[Fact]
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_mid", Rules(
grade: new[] { "middle" },
budget: new BudgetRangeDto(1000, 3000, "USD"))));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен мидл разработчик, 1 6002 200$."));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_mid", CancellationToken.None);
Assert.Equal(2, result.Card!.MatchHits.Count);
MatchHitDto grade = result.Card.MatchHits[0];
Assert.Equal("Грейд/уровень", grade.Label);
Assert.Equal("middle", grade.Term);
Assert.Equal("мидл", grade.Word); // найденный синоним
MatchHitDto budget = result.Card.MatchHits[1];
Assert.Equal("Бюджет", budget.Label); // сумма в USD в диапазоне 10003000 USD
}
// ─── Move из служебных колонок запрещён (code-review: вывод — только restore) ──
[Fact]
public async Task Move_FromArchive_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error);
Assert.Null(result.Card);
Assert.Equal(KanbanColumns.Archive, store.CardDtos.Single().Col); // карточка не тронута
Assert.Empty(store.Moves); // журнал не пишется
Assert.Empty(ml.Pushed); // обучение не шлётся
}
[Fact]
public async Task Move_FromTrash_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error);
Assert.Null(result.Card);
Assert.Equal(KanbanColumns.Trash, store.CardDtos.Single().Col);
Assert.Empty(store.Moves);
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
Assert.Equal(KanbanColumns.Trash, result!.Col);
Assert.False(result.IsNew);
Assert.Equal("inbox", result.PrevCol);
Assert.Empty(result.MatchHits); // корзина — служебная, hits пусто (Ruling 2)
CardMoveDto move = Assert.Single(store.Moves);
Assert.Equal("trash", move.Action);
Assert.Equal("inbox", move.FromCol);
Assert.Equal(KanbanColumns.Trash, move.ToCol);
(string text, string label, double delta) = Assert.Single(ml.Pushed); // «в корзину» = спам (Ruling 4)
Assert.Equal("Предлагаю услуги продвижения", text);
Assert.Equal("spam", label);
Assert.Equal(1.0, delta);
}
[Fact]
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
Assert.NotNull(result);
Assert.Empty(store.Moves); // _move L165166: в ту же колонку не переносим
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task Trash_FromArchive_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
Assert.Equal(KanbanColumns.Trash, result!.Col);
CardMoveDto move = Assert.Single(store.Moves);
Assert.Equal("trash", move.Action);
Assert.Equal(KanbanColumns.Archive, move.FromCol);
Assert.Empty(ml.Pushed); // карточка уже в archive — не «переучиваем» на спам (L198)
}
[Fact]
public async Task Trash_CardMissing_ReturnsNull()
{
(CardsService service, _, _, FakeMlClient ml) = Create();
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
Assert.Equal("b_py", back); // ответ {ok, col}
CardDto card = Assert.Single(store.CardDtos);
Assert.Equal("b_py", card.Col);
Assert.True(card.IsNew); // возврат — снова «новое» (L213)
Assert.Equal("inbox", card.PrevCol); // restore ставит prev_col='inbox' (Ruling 10)
Assert.Single(card.MatchHits); // hits пересчитаны для доски (Ruling 2)
CardMoveDto move = Assert.Single(store.Moves);
Assert.Equal("restore", move.Action);
Assert.Equal(KanbanColumns.Trash, move.FromCol);
Assert.Equal("b_py", move.ToCol);
(string text, string label, double delta) = Assert.Single(ml.Pushed); // возврат из корзины = не спам (L218–221)
Assert.Equal("Нужен Python-разработчик", text);
Assert.Equal("spam", label);
Assert.Equal(-1.0, delta);
}
[Fact]
public async Task Restore_FromArchive_NoPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
Assert.Equal("b_py", back);
CardMoveDto move = Assert.Single(store.Moves);
Assert.Equal("restore", move.Action);
Assert.Equal(KanbanColumns.Archive, move.FromCol);
Assert.Empty(ml.Pushed); // только возврат ИЗ корзины снимает метку spam (L218)
}
[Fact]
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
Assert.Equal("inbox", back);
CardDto card = Assert.Single(store.CardDtos);
Assert.Equal("inbox", card.Col);
Assert.Empty(card.MatchHits);
Assert.Equal(-1.0, Assert.Single(ml.Pushed).Delta); // из корзины — сигнал всё равно снимается
}
[Fact]
public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
Assert.Equal("inbox", back);
CardDto card = Assert.Single(store.CardDtos);
Assert.Equal("inbox", card.Col);
Assert.True(card.IsNew);
}
[Fact]
public async Task Restore_CardMissing_ReturnsNull()
{
(CardsService service, _, _, _) = Create();
string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None);
Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена»
}
[Fact]
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash,
sourceMsg: "Нужен Python-разработчик",
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
bool deleted = await service.DeleteForeverAsync("l_1", CancellationToken.None);
Assert.True(deleted);
Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_1"); // карточка удалена
Assert.Empty(store.CardDtos);
}
[Fact]
public async Task DeleteForever_JournalRowsSurviveCardDeletion()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); // журнал: 1 запись move
await service.DeleteForeverAsync("l_1", CancellationToken.None);
CardMoveDto move = Assert.Single(store.Moves);
Assert.Equal("move", move.Action);
Assert.Equal("l_1", move.LeadId);
}
[Fact]
public async Task DeleteForever_CardMissing_ReturnsFalse()
{
(CardsService service, _, _, _) = Create();
bool deleted = await service.DeleteForeverAsync("l_ghost", CancellationToken.None);
Assert.False(deleted); // эндпоинт отвечает 404 «Карточка не найдена»
}
[Fact]
public async Task ClearCol_Trash_ReturnsClearedCount()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash));
store.SeedCard(Card("l_2", KanbanColumns.Trash));
store.SeedCard(Card("l_3", KanbanColumns.Archive));
ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None);
Assert.Null(result.Error);
Assert.Equal(2, result.Cleared);
Assert.DoesNotContain(store.CardDtos, card => card.Col == KanbanColumns.Trash);
Assert.Single(store.CardDtos); // архив не тронут
}
[Fact]
public async Task ClearCol_EmptyTrash_ReturnsZero()
{
(CardsService service, _, _, _) = Create();
ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None);
Assert.Null(result.Error);
Assert.Equal(0, result.Cleared);
}
[Fact]
public async Task ClearCol_Board_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "b_py"));
ClearColResultDto result = await service.ClearColAsync("b_py", CancellationToken.None);
Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); // «Очищать можно только корзину или архив»
Assert.Single(store.CardDtos); // ничего не удалено
}
[Fact]
public async Task ClearCol_Inbox_Returns400Text()
{
(CardsService service, _, _, _) = Create();
ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Inbox, CancellationToken.None);
Assert.Equal(CardsService.ClearColInvalidDetail, result.Error);
}
[Fact]
public async Task MarkSeen_ById_OnlyThatCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true));
await service.MarkSeenAsync("l_1", null, CancellationToken.None);
Assert.False(Assert.Single(store.CardDtos, card => card.Id == "l_1").IsNew);
Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_2").IsNew);
}
[Fact]
public async Task MarkSeen_ByCol_OnlyColumnCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true));
store.SeedCard(Card("l_3", "b_py", isNew: true));
await service.MarkSeenAsync(null, "inbox", CancellationToken.None);
Assert.DoesNotContain(store.CardDtos, card => card.Col == "inbox" && card.IsNew);
Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_3").IsNew);
}
[Fact]
public async Task MarkSeen_All_WithoutParameters()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
await service.MarkSeenAsync(null, null, CancellationToken.None);
Assert.All(store.CardDtos, card => Assert.False(card.IsNew));
}
[Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
AddCommentResultDto nullText = await service.AddCommentAsync("l_1", string.Empty, CancellationToken.None);
Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий»
Assert.Equal(CardsService.EmptyCommentDetail, nullText.Error);
Assert.Empty(store.Moves);
}
[Fact]
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
Assert.Null(result.Error);
CardCommentDto comment = Assert.Single(result.Comments!);
Assert.StartsWith("cm_", comment.Id); // id комментария (Ruling 12)
Assert.Equal("Вы", comment.By);
Assert.Equal("Перезвонить завтра", comment.Text); // Trim (L262)
Assert.Equal("только что", comment.Time);
CardMoveDto move = Assert.Single(store.Moves); // комментарий — тоже действие журнала (L264)
Assert.Equal("comment", move.Action);
Assert.Null(move.FromCol);
Assert.Null(move.ToCol);
Assert.Single(Assert.Single(store.CardDtos).Comments); // комментарий сохранён на карточке
}
[Fact]
public async Task AddComment_CardMissing_ReturnsNullComments()
{
(CardsService service, _, _, _) = Create();
AddCommentResultDto result = await service.AddCommentAsync("l_ghost", "Текст", CancellationToken.None);
Assert.Null(result.Error);
Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена»
}
[Fact]
public async Task Counts_ColumnsNewAndMlStats()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox"));
store.SeedCard(Card("l_3", "b_py"));
store.SeedCard(Card("l_4", KanbanColumns.Trash, isNew: true));
ml.Status = MlStatus(learning: 7, ml: 2, ai: 3);
CardCountsDto counts = await service.CountsAsync(CancellationToken.None);
Assert.Equal(2, counts.New); // top-level new = сумма по колонкам (L274)
Assert.Equal(new CardColumnCountDto(2, 1), counts.Columns["inbox"]);
Assert.Equal(new CardColumnCountDto(1, 0), counts.Columns["b_py"]);
Assert.Equal(new CardColumnCountDto(1, 1), counts.Columns[KanbanColumns.Trash]);
Assert.Equal(7, counts.Learning); // из IMlClient.StatusAsync (план L321)
Assert.Equal(2, counts.Ml);
Assert.Equal(3, counts.Ai);
}
[Fact]
public async Task Counts_EmptyStore_ZeroNewAndStats()
{
(CardsService service, _, _, _) = Create();
CardCountsDto counts = await service.CountsAsync(CancellationToken.None);
Assert.Equal(0, counts.New);
Assert.Empty(counts.Columns);
Assert.Equal(0, counts.Learning);
}
[Fact]
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
IReadOnlyList<CardDto> blank = await service.SearchCardsAsync(" ", CancellationToken.None);
Assert.Empty(one); // min 2 символа (L511512)
Assert.Empty(blank);
Assert.Empty(store.SearchCalls); // порт не вызывается — поиск не имеет смысла
}
[Fact]
public async Task Search_DelegatesToStoreWithQueryAndLimit()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
CardDto card = Assert.Single(result);
Assert.Equal("l_1", card.Id);
(string query, int limit) = Assert.Single(store.SearchCalls);
Assert.Equal("python", query);
Assert.Equal(12, limit);
}
[Fact]
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200));
store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100));
store.SeedCard(Card("l_msg", "inbox", sourceMsg: "Нужен GO-разработчик", receivedAtMs: 400));
IReadOnlyList<CardDto> result = await service.SearchCardsAsync("pYtHoN", CancellationToken.None);
// Регистр не важен (lower LIKE %q%), порядок — received_at DESC.
Assert.Equal(new[] { "l_title", "l_summary", "l_contact" }, result.Select(card => card.Id));
}
[Fact]
public async Task Search_LimitIsTwelve()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
for (int i = 0; i < 15; i++)
{
store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i));
}
IReadOnlyList<CardDto> result = await service.SearchCardsAsync("python", CancellationToken.None);
Assert.Equal(12, result.Count); // limit=12 (L509)
Assert.Equal("l_14", result[0].Id); // свежие сверху
Assert.Equal("l_03", result[^1].Id); // 12 из 15 самых свежих
}
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml);
}
// Доска с правилами (ContainerRulesDto) либо без них.
private static ContainerDto Board(string id, ContainerRulesDto? rules = null) =>
new() { Id = id, Name = id, Rules = rules };
// Правила колонки с непустыми группами из параметров (как в ColumnRulesTests).
private static ContainerRulesDto Rules(
string mode = "all",
string[]? stack = null,
string[]? grade = null,
BudgetRangeDto? budget = null)
{
return new ContainerRulesDto(
mode,
Array.Empty<string>(),
Array.Empty<string>(),
stack ?? Array.Empty<string>(),
grade ?? Array.Empty<string>(),
Array.Empty<string>(),
budget);
}
// Карточка с полями по умолчанию (col=inbox, isNew=false, prevCol=inbox).
private static CardDto Card(
string id,
string col,
bool isNew = false,
string title = "",
string summary = "",
string contact = "",
string sourceMsg = "",
string prevCol = KanbanColumns.Inbox,
long receivedAtMs = 0,
IReadOnlyList<CardCommentDto>? comments = null)
{
return new CardDto
{
Id = id,
Col = col,
IsNew = isNew,
Title = title,
Summary = summary,
Contact = contact,
Content = new SourceContent { Text = sourceMsg },
PrevCol = prevCol,
ReceivedAtMs = receivedAtMs,
Comments = comments ?? Array.Empty<CardCommentDto>(),
};
}
// Ответ IMlClient.StatusAsync с заданными счётчиками (learning/ml/ai) для counts.
private static MlStatusResponseDto MlStatus(
int learning,
int ml,
int ai)
{
var service = new MlServiceStatusDto(
Ready: false,
Classes: new Dictionary<string, double>(),
Learned: 0,
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
var stats = new MlStatsDto(
Ml: ml,
Ai: ai,
Learning: learning,
Ready: false,
Classes: new Dictionary<string, double>(),
Learned: 0,
Reachable: true,
Outbox: 0);
return new MlStatusResponseDto(Enabled: true, Service: service, Reachable: true, Stats: stats);
}
}
@@ -0,0 +1,528 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Telegram.Application.Models;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты DialogsService — каталог диалогов/каналов и зеркало мониторинга ядра.
/// </summary>
public sealed class DialogsServiceTests
{
// ─── SyncFromTelegram: авто-мониторинг новых, обновление, удаление отсутствующих ──
/// <summary>
/// Новые диалоги при дефолтной autoMonitorNew=true появляются включёнными.
/// </summary>
[Fact]
public async Task Sync_NewDialogsAutoMonitorNewDefaultTrue_TurnMonitoringOn()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
int count = await service.SyncFromTelegramAsync(Catalog(
Entry("d_1", "Канал", "channel"),
Entry("d_2", "Группа", "group")), CancellationToken.None);
Assert.Equal(2, count);
Assert.Equal(["d_1", "d_2"], await service.ListMonitoredIdsAsync(CancellationToken.None));
Assert.True(store.Dialogs.All(row => row.Monitor));
}
/// <summary>
/// autoMonitorNew=false
/// </summary>
[Fact]
public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored()
{
(DialogsService service, FakeTelegramStore store, FakeSettingsStore settings, _) = Create();
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
Assert.Equal(1, count);
Assert.Single(store.Dialogs);
Assert.False(store.Dialogs[0].Monitor);
Assert.Empty(await service.ListMonitoredIdsAsync(CancellationToken.None));
}
/// <summary>
/// Существующий диалог обновляет имя/тип/handle, монитор пользователя не трогается.
/// </summary>
[Fact]
public async Task Sync_ExistingDialog_UpdatesMetadataAndKeepsUserMonitor()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Старое имя", "channel", Monitor: false, Backfilled: true));
await service.SyncFromTelegramAsync(
Catalog(new TelegramDialogEntryDto("d_1", "Новое имя", "new_handle", "group", "#a33")), CancellationToken.None);
FakeTelegramDialogRow row = Assert.Single(store.Dialogs);
Assert.Equal("Новое имя", row.Name);
Assert.Equal("group", row.Kind);
Assert.Equal("new_handle", row.Handle);
Assert.Equal("#a33", row.Hue);
Assert.False(row.Monitor); // монитор пользователя не трогаем
Assert.True(row.Backfilled);
}
/// <summary>
/// Диалог, которого больше нет в каталоге
/// </summary>
[Fact]
public async Task Sync_DialogLeftCatalog_IsDeleted()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_left", "Ушёл", "channel", Monitor: true, Backfilled: true));
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_stay", "Остался", "group")), CancellationToken.None);
Assert.Equal(1, count);
Assert.Equal(["d_stay"], store.Dialogs.Select(row => row.Id));
// Новый диалог каталога авто-мониторится (дефолт autoMonitorNew=true), ушедший — удалён.
Assert.Equal(["d_stay"], await service.ListMonitoredIdsAsync(CancellationToken.None));
}
/// <summary>
/// Пустой каталог — no-op, существующие строки целы.
/// </summary>
[Fact]
public async Task Sync_EmptyCatalog_ReturnsZeroAndKeepsRows()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
int count = await service.SyncFromTelegramAsync([], CancellationToken.None);
Assert.Equal(0, count);
Assert.Single(store.Dialogs);
}
// ─── SetMonitor: флаг + RPC в telegram-service + «нужен первый разбор» ──
/// <summary>
/// Первое включение неразобранного диалога
/// </summary>
[Fact]
public async Task SetMonitor_EnableFirstTimeNotBackfilled_SetsFlagCallsGatewayAndNeedsBackfill()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: false));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None);
Assert.True(store.Dialogs[0].Monitor);
Assert.True(result.Enabled);
Assert.True(result.BackfillNeeded);
Assert.Equal([("d_1", true)], gateway.SetMonitorCalls);
}
/// <summary>
/// Включение уже разобранного диалога
/// </summary>
[Fact]
public async Task SetMonitor_EnableAlreadyBackfilled_NoBackfillNeeded()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: true));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None);
Assert.True(store.Dialogs[0].Monitor);
Assert.False(result.BackfillNeeded);
Assert.Equal([("d_1", true)], gateway.SetMonitorCalls);
}
/// <summary>
/// Выключение: флаг off + RPC SetMonitor(id,false), разбор не нужен.
/// </summary>
[Fact]
public async Task SetMonitor_Disable_TurnsOffAndNotifiesMirror()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: false, CancellationToken.None);
Assert.False(store.Dialogs[0].Monitor);
Assert.False(result.Enabled);
Assert.False(result.BackfillNeeded);
Assert.Equal([("d_1", false)], gateway.SetMonitorCalls);
}
/// <summary>
/// Диалога нет в каталоге — no-op без RPC.
/// </summary>
[Fact]
public async Task SetMonitor_UnknownDialog_NoOpWithoutGatewayCall()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
TelegramMonitorToggleDto result = await service.SetMonitorAsync("missing", enabled: true, CancellationToken.None);
Assert.True(result.Enabled);
Assert.False(result.BackfillNeeded);
Assert.Empty(store.Dialogs);
Assert.Empty(gateway.SetMonitorCalls);
}
// ─── SetMonitorAll: все диалоги + зеркало + список неразобранных ──
/// <summary>
/// Включение всех: count строк, RPC SetMonitorAll(true), неразобранные — списком.
/// </summary>
[Fact]
public async Task SetMonitorAll_Enable_ReturnsCountAndNotBackfilledIdsAndNotifiesMirror()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_a", "А", "channel", Monitor: false, Backfilled: true));
store.Seed(Dialog("d_b", "Б", "channel", Monitor: false, Backfilled: false));
store.Seed(Dialog("d_c", "В", "group", Monitor: false, Backfilled: false));
TelegramMonitorAllDto result = await service.SetMonitorAllAsync(enabled: true, CancellationToken.None);
Assert.Equal(3, result.Count);
Assert.Equal(["d_b", "d_c"], result.BackfillNeededIds);
Assert.True(store.Dialogs.All(row => row.Monitor));
Assert.Equal([true], gateway.SetMonitorAllCalls);
}
/// <summary>
/// Выключение всех
/// </summary>
[Fact]
public async Task SetMonitorAll_Disable_TurnsAllOffAndEmptyBackfillList()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_a", "А", "channel", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_b", "Б", "channel", Monitor: true, Backfilled: true));
TelegramMonitorAllDto result = await service.SetMonitorAllAsync(enabled: false, CancellationToken.None);
Assert.Equal(2, result.Count);
Assert.Empty(result.BackfillNeededIds);
Assert.False(store.Dialogs.All(row => row.Monitor));
Assert.Equal([false], gateway.SetMonitorAllCalls);
}
/// <summary>
/// MarkBackfilled ставит флаг разобранности.
/// </summary>
[Fact]
public async Task MarkBackfilled_SetsFlagOnDialog()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
await service.MarkBackfilledAsync("d_1", CancellationToken.None);
Assert.True(store.Dialogs[0].Backfilled);
}
/// <summary>
/// «Перечитать» при падении одного канала продолжает остальные; флаг разбора — только после успеха.
/// </summary>
[Fact]
public async Task ReadRecent_OneDialogFails_ContinuesWithOthersAndMarksOnlySuccessful()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_3", "Форум", "forum", Monitor: true, Backfilled: false));
gateway.BackfillFailures.Add("d_2");
int count = await service.ReadRecentAsync(CancellationToken.None);
Assert.Equal(3, count);
Assert.Equal([("d_1", true), ("d_2", true), ("d_3", true)], gateway.BackfillCalls);
Assert.True(store.Dialogs.Single(row => row.Id == "d_1").Backfilled);
Assert.False(store.Dialogs.Single(row => row.Id == "d_2").Backfilled); // сбой RPC: флаг строго после успеха
Assert.True(store.Dialogs.Single(row => row.Id == "d_3").Backfilled);
}
/// <summary>
/// BackfillOne: нет строки/уже разобран без force — 0 без RPC; успех помечает разобранным.
/// </summary>
[Fact]
public async Task BackfillOne_UnknownOrAlreadyBackfilledWithoutForce_ReturnsZeroWithoutRpc()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_known", "Канал", "channel", Monitor: true, Backfilled: true));
store.Seed(Dialog("d_fresh", "Группа", "group", Monitor: false, Backfilled: false));
Assert.Equal(0, await service.BackfillOneAsync("d_missing", force: false, CancellationToken.None));
Assert.Equal(0, await service.BackfillOneAsync("d_known", force: false, CancellationToken.None));
Assert.Empty(gateway.BackfillCalls);
int processed = await service.BackfillOneAsync("d_fresh", force: false, CancellationToken.None);
Assert.Equal(3, processed);
Assert.Equal([("d_fresh", false)], gateway.BackfillCalls);
Assert.True(store.Dialogs.Single(row => row.Id == "d_fresh").Backfilled); // mark после успеха
}
/// <summary>
/// BackfillOne c force=true обходит флаг разобранности.
/// </summary>
[Fact]
public async Task BackfillOne_ForceTrue_BackfillsAlreadyBackfilledDialogAndMarksAgain()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
int processed = await service.BackfillOneAsync("d_1", force: true, CancellationToken.None);
Assert.Equal(3, processed);
Assert.Equal([("d_1", true)], gateway.BackfillCalls);
Assert.True(store.Dialogs.Single(row => row.Id == "d_1").Backfilled);
}
// ─── ReadRecent («Перечитать»): только включённые каналы, force=true ──
/// <summary>
/// Перечитываются только включённые диалоги
/// </summary>
[Fact]
public async Task ReadRecent_OnlyMonitoredDialogs_BackfillsEachMonitoredWithForce()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false));
int count = await service.ReadRecentAsync(CancellationToken.None);
Assert.Equal(2, count);
Assert.Equal([("d_1", true), ("d_2", true)], gateway.BackfillCalls);
Assert.True(store.Dialogs.Single(row => row.Id == "d_2").Backfilled); // после успешного разбора
Assert.False(store.Dialogs.Single(row => row.Id == "d_off").Backfilled); // выключенный не тронут
}
/// <summary>
/// Включённых нет — «Перечитать» возвращает 0 без вызовов гейта.
/// </summary>
[Fact]
public async Task ReadRecent_NoMonitoredDialogs_ReturnsZeroWithoutGatewayCalls()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false));
int count = await service.ReadRecentAsync(CancellationToken.None);
Assert.Equal(0, count);
Assert.Empty(gateway.BackfillCalls);
}
// ─── List ──────────────────────────────────────────────────────────────
/// <summary>
/// List возвращает каталог
/// </summary>
[Fact]
public async Task List_ReturnsMonitoredFirstByNameOrderWithLastMessage()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
DateTimeOffset lastAt = DateTimeOffset.UtcNow.AddMinutes(-5);
store.Seed(Dialog("d_gamma", "Гамма", "channel", Monitor: true, Backfilled: true, LastText: "привет", LastAt: lastAt));
store.Seed(Dialog("d_alpha", "Альфа", "group", Monitor: false, Backfilled: false));
store.Seed(Dialog("d_beta", "Бета", "chat", Monitor: false, Backfilled: false));
IReadOnlyList<TelegramDialogDto> items = await service.ListAsync(CancellationToken.None);
Assert.Equal(["d_gamma", "d_alpha", "d_beta"], items.Select(item => item.Id));
TelegramDialogDto first = items[0];
Assert.Equal("channel", first.Type);
Assert.True(first.On);
Assert.NotNull(first.Last);
Assert.Equal("привет", first.Last!.Text);
Assert.Equal(lastAt.ToUnixTimeMilliseconds(), first.Last.TimeMs);
Assert.Equal("Альфа", items[1].Name);
}
// ─── SavePreview: превью TgMessages + «последнее сообщение» каталога ──
/// <summary>
/// SavePreview пишет строку превью
/// </summary>
[Fact]
public async Task SavePreview_PersistsPreviewRowAndTouchesDialogLast()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
string longText = new('т', 5000);
DateTimeOffset msgAt = DateTimeOffset.UtcNow.AddMinutes(-1);
await service.SavePreviewAsync("d_1", msgId: 7, longText, msgAt, CancellationToken.None);
FakeTelegramMessageRow preview = Assert.Single(store.Messages);
Assert.Equal("m_d_1_7", preview.Id);
Assert.Equal("d_1", preview.DialogId);
Assert.Equal(4000, preview.Text.Length); // python L604: m.text[:4000]
Assert.Equal(msgAt, preview.MsgAt);
FakeTelegramDialogRow row = Assert.Single(store.Dialogs);
Assert.Equal(200, row.LastText.Length); // python L272: strip()[:200]
Assert.Equal(msgAt, row.LastAt);
}
/// <summary>
/// Пустой/пробельный текст — no-op.
/// </summary>
[Fact]
public async Task SavePreview_EmptyText_DoesNotTouchStore()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
await service.SavePreviewAsync("d_1", msgId: 7, " ", DateTimeOffset.UtcNow, CancellationToken.None);
Assert.Empty(store.Messages);
Assert.Empty(store.Dialogs[0].LastText);
Assert.Null(store.Dialogs[0].LastAt);
}
/// <summary>
/// Без msg_id строка превью не пишется
/// </summary>
[Fact]
public async Task SavePreview_NoMsgId_UpdatesLastOnly()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
await service.SavePreviewAsync("d_1", msgId: null, "Новое сообщение", null, CancellationToken.None);
Assert.Empty(store.Messages);
Assert.Equal("Новое сообщение", store.Dialogs[0].LastText);
Assert.NotNull(store.Dialogs[0].LastAt);
}
/// <summary>
/// Дубль превью того же сообщения не перезаписывает первую строку.
/// </summary>
[Fact]
public async Task SavePreview_DuplicateMessageId_KeepsFirstRow()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
DateTimeOffset firstAt = DateTimeOffset.UtcNow.AddMinutes(-2);
await service.SavePreviewAsync("d_1", msgId: 7, "Первое", firstAt, CancellationToken.None);
await service.SavePreviewAsync("d_1", msgId: 7, "Второе", DateTimeOffset.UtcNow, CancellationToken.None);
FakeTelegramMessageRow preview = Assert.Single(store.Messages);
Assert.Equal("Первое", preview.Text);
Assert.Equal(firstAt, preview.MsgAt);
}
/// <summary>
/// Новый источник после вступления
/// </summary>
[Fact]
public async Task AddDiscoveredMonitored_NewDialog_CreatesMonitoredRowAndNotifiesMirror()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
await service.AddDiscoveredMonitoredAsync("d_100", "Канал", "channel_handle", "channel", "#a33", CancellationToken.None);
FakeTelegramDialogRow row = Assert.Single(store.Dialogs);
Assert.Equal("d_100", row.Id);
Assert.Equal("Канал", row.Name);
Assert.Equal("channel_handle", row.Handle);
Assert.Equal("channel", row.Kind);
Assert.Equal("#a33", row.Hue);
Assert.True(row.Monitor);
Assert.False(row.Backfilled); // первый фоновый разбор подхватит последние сообщения (python L855)
Assert.Equal([("d_100", true)], gateway.SetMonitorCalls);
}
/// <summary>
/// Существующая строка
/// </summary>
[Fact]
public async Task AddDiscoveredMonitored_ExistingDialog_UpdatesMetadataAndForcesMonitorOn()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
store.Seed(Dialog("d_100", "Старое имя", "group", Monitor: false, Backfilled: true));
await service.AddDiscoveredMonitoredAsync("d_100", "Новое имя", "new_handle", "channel", "#c21", CancellationToken.None);
FakeTelegramDialogRow row = Assert.Single(store.Dialogs);
Assert.Equal("Новое имя", row.Name);
Assert.Equal("new_handle", row.Handle);
Assert.Equal("channel", row.Kind);
Assert.Equal("#c21", row.Hue);
Assert.True(row.Monitor);
Assert.False(row.Backfilled); // разбор нужен заново — строка могла быть до этого разобрана
Assert.Equal([("d_100", true)], gateway.SetMonitorCalls);
}
/// <summary>
/// Пустые имя/hue нормализуются как
/// </summary>
[Fact]
public async Task AddDiscoveredMonitored_EmptyNameAndHue_FallBackToDialogIdAndDefaultHue()
{
(DialogsService service, FakeTelegramStore store, _, _) = Create();
await service.AddDiscoveredMonitoredAsync("d_100", " ", string.Empty, string.Empty, string.Empty, CancellationToken.None);
FakeTelegramDialogRow row = Assert.Single(store.Dialogs);
Assert.Equal("d_100", row.Name);
Assert.Equal(string.Empty, row.Handle);
Assert.Equal(string.Empty, row.Kind);
Assert.Equal("#666", row.Hue);
}
/// <summary>
/// Сбой зеркала
/// </summary>
[Fact]
public async Task AddDiscoveredMonitored_GatewayMirrorFails_RowStillWritten()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
gateway.SetMonitorFailures.Add("d_100");
await service.AddDiscoveredMonitoredAsync("d_100", "Канал", string.Empty, "channel", "#666", CancellationToken.None);
Assert.True(Assert.Single(store.Dialogs).Monitor);
Assert.Empty(gateway.SetMonitorCalls);
}
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramStore — семантика адаптера; настройки/гейт чистые).
private static (DialogsService Service, FakeTelegramStore Store, FakeSettingsStore Settings, FakeTelegramGateway Gateway) Create()
{
var store = new FakeTelegramStore();
var settings = new FakeSettingsStore();
var gateway = new FakeTelegramGateway();
return (new DialogsService(store, settings, gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
}
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
private static FakeTelegramDialogRow Dialog(
string id,
string name,
string kind,
bool Monitor,
bool Backfilled,
string LastText = "",
DateTimeOffset? LastAt = null)
=> new(id, name, Handle: string.Empty, kind, Hue: "#666", Monitor, LastText, LastAt, Backfilled, UpdatedAt: DateTimeOffset.UtcNow);
// Запись каталога (id/имя/kind; handle/hue по умолчанию).
private static TelegramDialogEntryDto Entry(
string id,
string name,
string kind)
=> new(id, name, Handle: string.Empty, kind, Hue: "#666");
// Каталог из записей.
private static IReadOnlyList<TelegramDialogEntryDto> Catalog(params TelegramDialogEntryDto[] entries) => entries;
}
@@ -0,0 +1,202 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты DiscoveryEvaluator — каскад оценки сообщения и агрегаты выборки
/// </summary>
public sealed class DiscoveryEvaluatorTests
{
[Fact]
public async Task EvaluateMessage_ShortText_NotFitHeuristic()
{
(DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false);
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
TaskDto(keywords: ["заказ"]), "короткое", CancellationToken.None);
Assert.False(fit.Fit);
Assert.Equal("слишком короткое", fit.Reason);
Assert.Equal("heuristic", fit.Source);
}
[Fact]
public async Task EvaluateMessage_MlSpam_NotFit()
{
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true);
ml.Predict = new MlPredictResultDto(
Take: true, Label: "spam", Scores: new Dictionary<string, double>(), Hits: 3, Ready: true, Margin: 0.9,
Terms: Array.Empty<string>(), Type: null);
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
TaskDto(keywords: ["заказ"]), "Рассылка спама про дешёвые заказы и подработку в телеграме", CancellationToken.None);
Assert.False(fit.Fit);
Assert.Equal("ML: спам", fit.Reason);
Assert.Equal("ml", fit.Source);
}
[Fact]
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict()
{
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
ai.Fit = new AiEvaluateFitResultDto(Fit: true, "тема совпадает");
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
TaskDto(keywords: ["заказ"]), "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None);
Assert.True(fit.Fit);
Assert.Equal("тема совпадает", fit.Reason);
Assert.Equal("ai", fit.Source);
Assert.Equal(1, ai.EvaluateFitCalls);
}
[Fact]
public async Task EvaluateMessage_AiFailure_FallsBackToHeuristic()
{
(DiscoveryEvaluator evaluator, _, _, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
ai.Error = new InvalidOperationException("ai-service недоступен"); // как gRPC AiUnavailableException
DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]);
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
task, "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None);
Assert.True(fit.Fit); // по ключу «заказ» (эвристика, python L186194)
Assert.StartsWith("совпал ключ", fit.Reason, StringComparison.Ordinal);
Assert.Equal("heuristic", fit.Source);
}
[Fact]
public async Task EvaluateMessage_LocalNotSupported_FallsBackToHeuristic()
{
(DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: true, mlEnabled: false);
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
TaskDto(keywords: ["CRM"]), "Разработка CRM под ключ для агентств и студий", CancellationToken.None);
Assert.True(fit.Fit);
Assert.Equal("heuristic", fit.Source);
}
[Fact]
public async Task EvaluateMessage_HeuristicMatchesKeywordCaseInsensitive()
{
(DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false);
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
TaskDto(keywords: ["CRM"]), "Продаём crm-системы с внедрением", CancellationToken.None);
Assert.True(fit.Fit);
Assert.Equal("heuristic", fit.Source);
}
[Fact]
public async Task EvaluateMessage_NoKeywordMatch_NotFit()
{
(DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false);
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
TaskDto(keywords: ["заказ"]), "Обсуждаем стек и архитектуру нового проекта", CancellationToken.None);
Assert.False(fit.Fit);
Assert.Equal("нет совпадений с ключами", fit.Reason);
}
[Fact]
public async Task EvaluateSample_ComputesAggregate()
{
(DiscoveryEvaluator evaluator, _, _, _) = Create(aiEnabled: false, mlEnabled: false);
DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]);
DiscoveryEvalSample sample = await evaluator.EvaluateSampleAsync(
task,
["Нужен заказ на месяц", "Обсуждаем заказ на разработку", "Привет, как дела?", "Общие разговоры", "Снова заказ"],
CancellationToken.None);
Assert.Equal(3, sample.FitCount);
Assert.Equal(5, sample.Total);
Assert.Equal(0.6, sample.FitRatio);
}
[Fact]
public void Passed_RequiresThreeMessagesAndThresholdRatio()
{
Assert.True(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(2, 4, 0.5, []), thresholdPercent: 40));
Assert.True(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(4, 4, 1.0, []), thresholdPercent: 40));
Assert.False(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(1, 4, 0.25, []), thresholdPercent: 40));
Assert.False(DiscoveryEvaluator.Passed(new DiscoveryEvalSample(2, 2, 1.0, []), thresholdPercent: 40)); // < 3 сообщений
}
[Fact]
public void GroupByTopic_GroupsAndSortsBySizeWithTitles()
{
IReadOnlyList<TelegramEvalMessageDto> messages =
[
Msg(1, "Первая тема: ищем заказ", topic: 10),
Msg(2, "Проект для заказчика", topic: 10),
Msg(3, "Основной чат без темы", topic: null),
Msg(4, " Вторая тема ", topic: 20),
Msg(5, "Ещё основное сообщение", topic: null),
Msg(6, "Обсуждение заказа", topic: null),
];
IReadOnlyList<DiscoveryTopicGroup> groups = DiscoveryEvaluator.GroupByTopic(messages);
// Сортировка по числу сообщений (убыв.); topic_id=null → «main»; title — сниппет первого текста ≤60.
Assert.Equal(3, groups.Count);
Assert.Equal("main", groups[0].TopicKey);
Assert.Equal(3, groups[0].Messages.Count);
Assert.Equal("Основной чат без темы", groups[0].Title);
Assert.Equal("10", groups[1].TopicKey);
Assert.Equal("Первая тема: ищем заказ", groups[1].Title);
Assert.Equal("20", groups[2].TopicKey);
Assert.Equal("Вторая тема", groups[2].Title);
}
// Сообщение выборки (текст; для форума — с темой).
private static TelegramEvalMessageDto Msg(
long id,
string text,
long? topic)
=> new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null);
// Создаёт оценщик на фейках: флаги веток выставляются сценарием.
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, FakeAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
{
var settings = new FakeSettingsStore();
settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false");
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
var ml = new FakeMlClient();
var ai = new FakeAiTools();
return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai);
}
// Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold).
private static DiscoveryTaskDto TaskDto(string[] keywords, string description = "Ниша: разработка под заказ")
{
return new DiscoveryTaskDto(
"dt_1",
"Задача",
description,
keywords,
MinSubscribers: 0,
Lang: "ru",
Threshold: 40,
SampleSize: 10,
PlanJoins: 1,
AutoJoin: false,
Status: DiscoveryTaskStatuses.Running,
SearchIdx: 0,
SearchDone: true,
Found: 0,
Evaluated: 0,
Joined: 0,
Rejected: 0,
CreatedAt: 1000,
UpdatedAt: 1000);
}
}
@@ -0,0 +1,544 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Discovery;
using Deal.Tests.Unit.Modules.Settings;
using Grpc.Core;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты DiscoveryWorkerService — один шаг за тик
/// </summary>
public sealed class DiscoveryWorkerServiceTests
{
// ─── Поиск ─────────────────────────────────────────────────────────────
[Fact]
public async Task Tick_SearchCreatesCandidatesAndFinishesKeywordsPass()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["вакансия"], searchDone: false));
fx.Gateway.SearchResults =
[
Entry("d1", "Вакансии для разработчиков", "job_ch", DiscoveryCandidateKinds.Channel),
Entry("l1", "Лена", string.Empty, "chat"),
];
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionSearch, "dt_1"), (outcome.Action, outcome.TaskId));
DiscoveryCandidateDto candidate = Assert.Single(fx.Store.Candidates);
Assert.Equal("d1", candidate.DialogId);
Assert.Equal("job_ch", candidate.Username);
Assert.Equal(DiscoveryCandidateKinds.Channel, candidate.Kind);
DiscoveryTaskDto task = fx.Store.Tasks.Single();
Assert.Equal(1, task.SearchIdx);
Assert.True(task.SearchDone);
Assert.Equal(1, task.Found);
Assert.Equal(["вакансия"], fx.Gateway.SearchedKeywords);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Search && log.Text == "поиск завершён: 1 кандидатов");
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "Лена: личный чат/бот");
}
[Fact]
public async Task Tick_SearchErrorThreeTimes_SkipsBrokenKeyword()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["битый ключ"], searchDone: false));
fx.Gateway.SearchError = new InvalidOperationException("Telegram недоступен");
DiscoveryWorkerOutcome first = await fx.Worker.TickOnceAsync(CancellationToken.None);
DiscoveryWorkerOutcome second = await fx.Worker.TickOnceAsync(CancellationToken.None);
DiscoveryWorkerOutcome third = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionError, first.Action);
Assert.Equal(DiscoveryWorkerService.ActionError, second.Action);
Assert.Equal(DiscoveryWorkerService.ActionError, third.Action); // 3-я ошибка — ключ пропущен (python L196203)
DiscoveryTaskDto task = fx.Store.Tasks.Single();
Assert.Equal(1, task.SearchIdx);
Assert.True(task.SearchDone);
Assert.Contains(fx.Store.Log, log => log.Text.Contains("ключ пропущен (3 ошибки подряд)", StringComparison.Ordinal));
Assert.Equal(["битый ключ", "битый ключ", "битый ключ"], fx.Gateway.SearchedKeywords);
}
[Fact]
public async Task Tick_SearchFlood_NotesFloodDayAndStops()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["ключ"], searchDone: false));
fx.Gateway.SearchError = FloodException();
DiscoveryWorkerOutcome flood = await fx.Worker.TickOnceAsync(CancellationToken.None);
DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionFlood, flood.Action);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Flood && log.Text.Contains("flood — стоп до конца суток"));
Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action); // flood-день: воркер стоит (python L448451)
Assert.Single(fx.Gateway.SearchedKeywords); // второй тик сеть не дёргал
}
// ─── Оценка кандидата ──────────────────────────────────────────────────
[Fact]
public async Task Tick_EvalPassed_MovesCandidateToReviewWithFitRatio()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New, kind: DiscoveryCandidateKinds.Group, username: "grp1"));
fx.Gateway.InfoByDialog["d1"] = Info("d1", "Группа заказов", "grp1", "group", participants: 120);
fx.Gateway.ReadsByDialog["d1"] = Read(
"Нужен заказ на разработку CRM", "Ищем заказ на месяц для клиента", "Обсуждаем стек проекта", "Всем привет!");
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionReview, "dt_1"), (outcome.Action, outcome.TaskId));
DiscoveryCandidateDto candidate = fx.Store.Candidates.Single();
Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status);
Assert.True(candidate.LangRu);
Assert.Equal(0.5, candidate.FitRatio);
Assert.Empty(candidate.Marks);
Assert.Equal(1, fx.Store.Tasks.Single().Evaluated);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Review && log.Text.Contains("d1"));
Assert.Equal(120, candidate.Participants);
Assert.Equal("Группа заказов", candidate.Name);
}
[Fact]
public async Task Tick_EvalTooFewSubscribers_DeletesCandidate()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, minSubscribers: 500));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New));
fx.Gateway.InfoByDialog["d1"] = Info("d1", "Канал", string.Empty, "channel", participants: 120);
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId));
Assert.Empty(fx.Store.Candidates);
Assert.Equal(1, fx.Store.Tasks.Single().Evaluated);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: мало участников (120 < 500)");
}
[Fact]
public async Task Tick_EvalNotRussianLanguage_DeletesCandidate()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["order"], searchDone: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New));
fx.Gateway.InfoByDialog["d1"] = Info("d1", "Channel", string.Empty, "channel", participants: 10);
fx.Gateway.ReadsByDialog["d1"] = Read(
"We need order for crm", "Looking for developers", "Please contact us today", "Hello everyone");
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId));
Assert.Empty(fx.Store.Candidates);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: язык не русский");
}
[Fact]
public async Task Tick_EvalFewMessages_ReviewWithMark()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New));
fx.Gateway.InfoByDialog["d1"] = Info("d1", "Группа", string.Empty, "group", participants: 10);
fx.Gateway.ReadsByDialog["d1"] = Read("Нужен заказ на разработку", "Привет всем участникам");
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionReview, outcome.Action);
DiscoveryCandidateDto candidate = fx.Store.Candidates.Single();
Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status);
Assert.Contains(DiscoveryWorkerService.MarkFewMessages, candidate.Marks);
Assert.Null(candidate.FitRatio); // контент не оценивался — решает человек (python L291296)
}
[Fact]
public async Task Tick_EvalNoHistory_ClosedGroupMarks()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New, kind: DiscoveryCandidateKinds.Group));
fx.Gateway.InfoByDialog["d1"] = Info("d1", "Закрытая группа", string.Empty, "group", participants: 300);
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionReview, outcome.Action);
DiscoveryCandidateDto candidate = fx.Store.Candidates.Single();
Assert.Contains(DiscoveryWorkerService.MarkClosedGroup, candidate.Marks);
Assert.Contains(DiscoveryWorkerService.MarkLangNotConfirmed, candidate.Marks); // ru-задача: язык не подтверждён
Assert.Null(candidate.FitRatio);
}
[Fact]
public async Task Tick_EvalContentFailed_DeletesCandidateWithFewFitLog()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, threshold: 60));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New));
fx.Gateway.InfoByDialog["d1"] = Info("d1", "Канал", string.Empty, "channel", participants: 10);
fx.Gateway.ReadsByDialog["d1"] = Read(
"Нужен заказ на разработку CRM", "Обсуждаем архитектуру", "Просто болтаем", "Привет всем участникам");
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionSkip, "dt_1"), (outcome.Action, outcome.TaskId));
Assert.Empty(fx.Store.Candidates);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text == "d1: мало подходящих (1 из 4)");
}
[Fact]
public async Task Tick_EvalForum_ByTopicsWithAnyPassedTopic_MovesToReview()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.New));
fx.Gateway.InfoByDialog["d1"] = Info("d1", "Форум заказов", "forum1", "group", participants: 300, isForum: true);
fx.Gateway.ReadsByDialog["d1"] = new TelegramEvalReadDto(
Ok: true,
Error: null,
new List<TelegramEvalMessageDto>
{
MsgWithTopic(1, "Нужен заказ на разработку CRM", 10),
MsgWithTopic(2, "Ищем заказ на месяц", 10),
MsgWithTopic(3, "Обсуждаем интеграции", 10),
MsgWithTopic(4, "Заказ для студии", 20),
});
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionReview, "dt_1"), (outcome.Action, outcome.TaskId));
DiscoveryCandidateDto candidate = fx.Store.Candidates.Single();
Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status);
Assert.Equal(DiscoveryCandidateKinds.Forum, candidate.Kind); // is_forum → kind forum (Ruling 10)
DiscoveryTopicDto topic10 = Assert.Single(candidate.Topics, topic => topic.TopicId == "10");
Assert.True(topic10.Passed);
DiscoveryTopicDto topic20 = Assert.Single(candidate.Topics, topic => topic.TopicId == "20");
Assert.False(topic20.Passed); // тема из одного сообщения (<3) порог не проходит (passed L229237)
Assert.Equal(3, candidate.Topics.Sum(topic => topic.FitCount));
Assert.Equal(0.75, candidate.FitRatio); // агрегат fit из N по всей выборке форума
}
// ─── План выполнен → done ──────────────────────────────────────────────
[Fact]
public async Task Tick_PlanReached_MarksTaskDoneAndLogs()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", searchDone: false, joined: 1, planJoins: 1));
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionDone, "dt_1"), (outcome.Action, outcome.TaskId));
DiscoveryTaskDto task = fx.Store.Tasks.Single();
Assert.Equal(DiscoveryTaskStatuses.Done, task.Status);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Done && log.Text == "план выполнен: вступили 1 из 1");
}
[Fact]
public async Task Tick_AfterDone_NoFurtherActions()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", searchDone: false, joined: 1, planJoins: 1));
Assert.Equal(DiscoveryWorkerService.ActionDone, (await fx.Worker.TickOnceAsync(CancellationToken.None)).Action);
DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action); // done — терминальный: тик пуст (идемпотентность)
Assert.Equal(1, fx.Store.Tasks.Single().Joined); // счётчики повторными тиками не меняются
}
// ─── Авто-вступление ───────────────────────────────────────────────────
[Fact]
public async Task Tick_JoinWithPause_MarksJoinedAndTurnsMonitorOn()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", keywords: ["заказ"], searchDone: true, autoJoin: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, kind: DiscoveryCandidateKinds.Group, username: "grp1"));
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionJoin, "dt_1"), (outcome.Action, outcome.TaskId));
Assert.Equal(1, fx.Pacer.DelayCalls); // пауза между вступлениями была (в проде 50–70 с)
Assert.Equal(["grp1"], fx.Gateway.JoinedUsernames);
DiscoveryCandidateDto candidate = fx.Store.Candidates.Single();
Assert.Equal(DiscoveryCandidateStatuses.Joined, candidate.Status);
Assert.True(candidate.AutoJoined);
Assert.Equal(1, fx.Store.Tasks.Single().Joined);
Assert.Equal([("d1", true)], fx.Gateway.SetMonitorCalls); // монитор-зеркало включено (python add_dialog_monitored)
Assert.Equal([("d1", false)], fx.Gateway.BackfillCalls); // фоновый разбор последних сообщений
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.JoinAuto);
}
[Fact]
public async Task Tick_JoinCandidateAlreadyMonitored_RejectsCandidate()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true));
fx.Store.SeedMonitored("d1");
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review));
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal((DiscoveryWorkerService.ActionReject, "dt_1"), (outcome.Action, outcome.TaskId));
Assert.Empty(fx.Gateway.JoinedUsernames);
DiscoveryCandidateDto candidate = fx.Store.Candidates.Single();
Assert.Equal(DiscoveryCandidateStatuses.Rejected, candidate.Status);
Assert.Single(fx.Store.Blacklist);
Assert.Equal(1, fx.Store.Tasks.Single().Rejected);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Reject && log.Text.Contains("уже вступили между оценкой и авто-вступлением"));
}
[Fact]
public async Task Tick_JoinFlood_StopsAutoJoinsUntilEndOfDay()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1"));
fx.Gateway.JoinError = FloodException();
DiscoveryWorkerOutcome flood = await fx.Worker.TickOnceAsync(CancellationToken.None);
DiscoveryWorkerOutcome next = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionFlood, flood.Action);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Flood && log.Text.Contains("авто-вступление d1: flood"));
Assert.Equal(DiscoveryWorkerService.ActionNone, next.Action);
DiscoveryCandidateDto candidate = fx.Store.Candidates.Single();
Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); // кандидат остаётся review для повтора
}
[Fact]
public async Task Tick_JoinFailures_AfterThreeDeletesCandidate()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1"));
fx.Gateway.JoinError = new InvalidOperationException("JoinChannel 400: USERNAME_NOT_OCCUPIED");
DiscoveryWorkerOutcome first = await fx.Worker.TickOnceAsync(CancellationToken.None);
DiscoveryWorkerOutcome second = await fx.Worker.TickOnceAsync(CancellationToken.None);
DiscoveryWorkerOutcome third = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionError, first.Action);
Assert.Equal(DiscoveryWorkerService.ActionError, second.Action);
Assert.Equal(DiscoveryWorkerService.ActionSkip, third.Action); // 3-я неудача — кандидат удаляется (python L417420)
Assert.Equal(3, fx.Pacer.DelayCalls);
Assert.Empty(fx.Store.Candidates);
Assert.Contains(fx.Store.Log, log => log.Event == DiscoveryLogEvents.Skip && log.Text.Contains("не удалось вступить (3 попытки)"));
}
[Fact]
public async Task Tick_JoinStateChangedDuringPause_ExitsWithoutJoin()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1"));
// За «паузу» 50–70 с задачу поставили на паузу вручную: повторная проверка после паузы → выход без join.
fx.Pacer.OnDelay = () => fx.Tasks.PauseAsync("dt_1", CancellationToken.None).GetAwaiter().GetResult();
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action);
Assert.Empty(fx.Gateway.JoinedUsernames);
DiscoveryCandidateDto candidate = fx.Store.Candidates.Single();
Assert.Equal(DiscoveryCandidateStatuses.Review, candidate.Status); // кандидат не тронут
}
[Fact]
public async Task Tick_JoinDailyQuotaReached_NoJoin()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", searchDone: true, autoJoin: true));
fx.Store.SeedCandidate(Candidate("d1", "dt_1", DiscoveryCandidateStatuses.Review, username: "grp1"));
fx.Settings.Preload(SettingsKeys.DiscJoinLimit, "1");
fx.Store.SeedLog(DiscoveryLogEvents.JoinAuto, UtcDayStart()); // суточная квота исчерпана
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action);
Assert.Empty(fx.Gateway.JoinedUsernames);
Assert.Equal(0, fx.Pacer.DelayCalls); // join никому нельзя — пауза даже не запрашивалась (python L480481)
}
[Fact]
public async Task Tick_GlobalPaused_NoActionsAtAll()
{
Fixture fx = Create();
fx.Store.SeedTask(Task("dt_1", searchDone: false));
fx.Settings.Preload(SettingsKeys.DiscPaused, "true");
DiscoveryWorkerOutcome outcome = await fx.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionNone, outcome.Action);
Assert.Empty(fx.Gateway.SearchedKeywords);
}
// ─── Изоляция независимых тенантов ─────────────────────────────────────
[Fact]
public async Task TicksAcrossTenantStores_DoNotInterfere()
{
// Два независимых набора «тенантов» (стор/гейт/настройки): поиск у A и оценка у B в одном проходе
// не пересекаются — каждое хранилище меняет только свои строки, лог/чёрный список своих тенантов.
Fixture fxA = Create();
Fixture fxB = Create();
fxA.Store.SeedTask(Task("dt_A", keywords: ["заказ A"], searchDone: false));
fxA.Gateway.SearchResults = [Entry("dA", "Канал A", "ch_a", DiscoveryCandidateKinds.Channel)];
fxB.Store.SeedTask(Task("dt_B", keywords: ["заказ B"], searchDone: true));
fxB.Store.SeedCandidate(Candidate("dB", "dt_B", DiscoveryCandidateStatuses.New));
fxB.Gateway.InfoByDialog["dB"] = Info("dB", "Канал B", string.Empty, "channel", participants: 10);
fxB.Gateway.ReadsByDialog["dB"] = Read("Нужен заказ B на разработку", "Ищем заказ B на месяц", "Привет всем участникам", "Обсуждения");
DiscoveryWorkerOutcome outcomeA = await fxA.Worker.TickOnceAsync(CancellationToken.None);
DiscoveryWorkerOutcome outcomeB = await fxB.Worker.TickOnceAsync(CancellationToken.None);
Assert.Equal(DiscoveryWorkerService.ActionSearch, outcomeA.Action);
Assert.Equal(DiscoveryWorkerService.ActionReview, outcomeB.Action);
Assert.Single(fxA.Store.Candidates, candidate => candidate.TaskId == "dt_A");
DiscoveryCandidateDto candidateB = fxB.Store.Candidates.Single();
Assert.Equal(DiscoveryCandidateStatuses.Review, candidateB.Status);
Assert.Equal(["заказ A"], fxA.Gateway.SearchedKeywords);
Assert.Empty(fxB.Gateway.SearchedKeywords); // задача B уже прошла поиск — тик шёл оценкой
Assert.Empty(fxA.Store.Blacklist);
Assert.Empty(fxB.Store.Blacklist);
Assert.DoesNotContain(fxA.Store.Log, log => log.TaskId == "dt_B");
Assert.DoesNotContain(fxB.Store.Log, log => log.TaskId == "dt_A");
}
// ─── Контекст и хелперы ─────────────────────────────────────────────────
// Контекст теста: воркер на фейках + ссылки для проверок состояния.
private sealed record Fixture(
FakeDiscoveryStore Store,
FakeSettingsStore Settings,
FakeDiscoveryGateway Gateway,
FakeDiscoveryPacer Pacer,
DiscoveryWorkerService Worker,
DiscoveryTasksService Tasks);
// Создаёт воркер на общих фейках; ML/ИИ выключены — оценка идёт эвристикой по ключам.
private static Fixture Create()
{
var store = new FakeDiscoveryStore();
var settings = new FakeSettingsStore();
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new FakeMlClient();
var ai = new FakeAiTools();
var log = new DiscoveryLogService(store);
var blacklist = new DiscoveryBlacklistService(store);
var candidates = new DiscoveryCandidatesService(store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store, settings);
var tasks = new DiscoveryTasksService(store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store, settings);
var pacer = new FakeDiscoveryPacer();
var gateway = new FakeDiscoveryGateway();
var worker = new DiscoveryWorkerService(
store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway);
return new Fixture(store, settings, gateway, pacer, worker, tasks);
}
// Задача как строка DiscTasks (адаптер-форма).
private static DiscoveryTaskDto Task(
string id,
string[]? keywords = null,
bool searchDone = true,
bool autoJoin = false,
int planJoins = 5,
int joined = 0,
int minSubscribers = 0,
int threshold = 40)
{
return new DiscoveryTaskDto(
id,
"Задача",
string.Empty,
keywords ?? new[] { "заказ" },
minSubscribers,
Lang: "ru",
threshold,
SampleSize: 10,
planJoins,
autoJoin,
Status: DiscoveryTaskStatuses.Running,
SearchIdx: 0,
searchDone,
Found: 0,
Evaluated: 0,
joined,
Rejected: 0,
CreatedAt: 1000,
UpdatedAt: 1000);
}
// Кандидат как строка DiscCandidates (адаптер-форма).
private static DiscoveryCandidateDto Candidate(
string dialogId,
string taskId,
string status,
string kind = DiscoveryCandidateKinds.Channel,
string username = "")
{
return new DiscoveryCandidateDto(
dialogId,
taskId,
dialogId,
username,
kind,
"#666",
Participants: null,
LangRu: null,
new List<string>(),
new List<DiscoveryTopicDto>(),
FitRatio: null,
status,
AutoJoined: false,
JoinFailures: 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");
// Инфо источника (proto ChannelInfo).
private static TelegramChannelInfoDto Info(
string id,
string name,
string username,
string kind,
int? participants,
bool isForum = false)
=> new(id, name, username, kind, "#666", participants, isForum);
// Сообщение форума с темой (proto EvalMessage).
private static TelegramEvalMessageDto MsgWithTopic(
long id,
string text,
long? topic)
=> new(id, text, 1_700_000_000_000 + id, topic, TopicTitle: null);
// Выборка сообщений (ok=true), тексты в порядке входных аргументов.
private static TelegramEvalReadDto Read(params string[] texts)
=> new(Ok: true, Error: null, texts.Select((text, index) => new TelegramEvalMessageDto(index + 1, text, 1_700_000_000_000 + index, TopicId: null, TopicTitle: null)).ToList());
// Начало текущих UTC-суток (эпоха-ms; seed-квота бан-гарда, которая считает по UTC-дню).
private static DateTimeOffset UtcDayStart()
{
DateTime now = DateTime.UtcNow;
return new DateTimeOffset(now.Year, now.Month, now.Day, 0, 0, 0, TimeSpan.Zero);
}
private static RpcException FloodException() =>
new(new Status(StatusCode.ResourceExhausted, "flood: FLOOD_WAIT_120"));
}
@@ -0,0 +1,66 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="IAiClassifier"/> для unit-тестов воркера pump.
/// </summary>
public sealed class FakeAiClassifier : IAiClassifier
{
/// <summary>
/// Ответ FilterAsync по умолчанию — как LocalAiClassifier
/// </summary>
public AiFilterResultDto FilterResult { get; set; } = new(Pass: true, Reason: null, Skipped: true);
/// <summary>
/// Ответ ClassifyAsync; null — ClassifyAsync бросает NotSupportedException
/// </summary>
public AiParsedCardDto? ClassifyResult { get; set; }
/// <summary>
/// Сбой FilterAsync.
/// </summary>
public bool FilterThrows { get; set; }
/// <summary>
/// Сбой ClassifyAsync.
/// </summary>
public bool ClassifyThrows { get; set; }
/// <summary>
/// Сколько раз вызван FilterAsync
/// </summary>
public int FilterCalls { get; private set; }
/// <summary>
/// Сколько раз вызван ClassifyAsync
/// </summary>
public int ClassifyCalls { get; private set; }
/// <inheritdoc />
public Task<AiFilterResultDto> FilterAsync(string text, CancellationToken ct)
{
FilterCalls++;
if (FilterThrows)
{
throw new InvalidOperationException("Тестовый сбой ИИ-фильтра (FilterThrows).");
}
return Task.FromResult(FilterResult);
}
/// <inheritdoc />
public Task<AiParsedCardDto> ClassifyAsync(string text, CancellationToken ct)
{
ClassifyCalls++;
if (ClassifyThrows)
{
throw new InvalidOperationException("Тестовый сбой классификатора (ClassifyThrows).");
}
return ClassifyResult is null
? throw new NotSupportedException()
: Task.FromResult(ClassifyResult);
}
}
@@ -0,0 +1,47 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Фейковая реализация <see cref="IAiTools"/> для тестов Discovery-воркера/оценки.
/// </summary>
public sealed class FakeAiTools : IAiTools
{
/// <summary>
/// Ответ EvaluateFitAsync; null — вызов бросит <see cref="NotSupportedException"/>
/// </summary>
public AiEvaluateFitResultDto? Fit { get; set; }
/// <summary>
/// Явный сбой EvaluateFitAsync
/// </summary>
public Exception? Error { get; set; }
/// <summary>
/// Сколько раз вызван EvaluateFitAsync
/// </summary>
public int EvaluateFitCalls { get; private set; }
/// <inheritdoc />
public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text,
string description,
IReadOnlyCollection<string> keywords,
CancellationToken ct)
{
EvaluateFitCalls++;
if (Error is not null)
{
throw Error;
}
return Fit is null
? throw new NotSupportedException()
: Task.FromResult(Fit);
}
/// <inheritdoc />
public Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException();
}
@@ -0,0 +1,192 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Фейковый <see cref="ITelegramGateway"/> для тестов Discovery-воркера
/// </summary>
public sealed class FakeDiscoveryGateway : ITelegramGateway
{
/// <summary>
/// Результат глобального поиска
/// </summary>
public IReadOnlyList<TelegramDialogEntryDto> SearchResults { get; set; } = [];
/// <summary>
/// Исключение SearchAsync
/// </summary>
public Exception? SearchError { get; set; }
/// <summary>
/// Инфо об источниках по dialogId; отсутствующий — fallback как Local
/// </summary>
public Dictionary<string, TelegramChannelInfoDto> InfoByDialog { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Выборки сообщений по dialogId; отсутствующий — ok=false "no_history"
/// </summary>
public Dictionary<string, TelegramEvalReadDto> ReadsByDialog { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Исключение JoinAsync
/// </summary>
public Exception? JoinError { get; set; }
/// <summary>
/// Сколько сообщений «разобрал» Backfill
/// </summary>
public int BackfillResult { get; set; } = 3;
/// <summary>
/// Поисковые запросы в порядке вызовов.
/// </summary>
public List<string> SearchedKeywords { get; } = [];
/// <summary>
/// Запросы Info
/// </summary>
public List<string> InfoRequests { get; } = [];
/// <summary>
/// Запросы ReadForEval
/// </summary>
public List<(string DialogId, int Limit)> ReadRequests { get; } = [];
/// <summary>
/// Вызовы Join (username без «@») в порядке вызовов.
/// </summary>
public List<string> JoinedUsernames { get; } = [];
/// <summary>
/// Вызовы SetMonitor
/// </summary>
public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = [];
/// <summary>
/// Вызовы Backfill
/// </summary>
public List<(string DialogId, bool Force)> BackfillCalls { get; } = [];
/// <inheritdoc />
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
=> Task.FromResult(new TelegramAccountStatusDto("ready", Connected: true, Listener: true, "@test", Error: null, QrUrl: null));
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartPhoneAsync(
string phone,
int apiId,
string apiHash,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<string> SendCodeAsync(string code, CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc />
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc />
public Task LogoutAsync(CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{
SetMonitorCalls.Add((dialogId, enabled));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc />
public Task<int> BackfillAsync(
string dialogId,
bool force,
CancellationToken ct)
{
BackfillCalls.Add((dialogId, force));
return Task.FromResult(BackfillResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query,
int limit,
CancellationToken ct)
{
SearchedKeywords.Add(query);
if (SearchError is not null)
{
throw SearchError;
}
return Task.FromResult(SearchResults);
}
/// <inheritdoc />
public Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
{
InfoRequests.Add(dialogId);
return Task.FromResult(
InfoByDialog.TryGetValue(dialogId, out TelegramChannelInfoDto? info)
? info
: new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, "#666", Participants: null, IsForum: false));
}
/// <inheritdoc />
public Task<TelegramEvalReadDto> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
{
ReadRequests.Add((dialogId, limit));
return Task.FromResult(
ReadsByDialog.TryGetValue(dialogId, out TelegramEvalReadDto? read)
? read
: new TelegramEvalReadDto(Ok: false, "no_history", []));
}
/// <inheritdoc />
public Task JoinAsync(string username, CancellationToken ct)
{
JoinedUsernames.Add(username);
if (JoinError is not null)
{
throw JoinError;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task LeaveAsync(string dialogId, CancellationToken ct) => throw new NotSupportedException();
}
@@ -0,0 +1,85 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="IFileStorage"/> для unit-тестов файлов карточки
/// </summary>
public sealed class FakeFileStorage : IFileStorage
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _contentTypes = new(StringComparer.Ordinal);
private readonly List<string> _deletedKeys = [];
/// <summary>
/// objectKey всех объектов, сохранённых на данный момент
/// </summary>
public IReadOnlyList<string> StoredObjectKeys => _objects.Keys.ToList();
/// <summary>
/// objectKey всех удалений в порядке вызовов DeleteAsync
/// </summary>
public IReadOnlyList<string> DeletedKeys => _deletedKeys.ToList();
/// <summary>
/// Содержимое сохранённого объекта по ключу либо null — объекта нет
/// </summary>
/// <param name="objectKey">Ключ объекта (opaque).</param>
public byte[]? ContentOf(string objectKey)
{
return _objects.TryGetValue(objectKey, out byte[]? content) ? content : null;
}
/// <inheritdoc />
public Task<string> PutAsync(
string objectKey,
Stream content,
string contentType,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(content);
using MemoryStream buffer = new();
if (content.CanSeek && content.Position != 0)
{
content.Position = 0; // контракт порта: Put читает всё содержимое с позиции 0 (Ruling T6)
}
content.CopyTo(buffer);
_objects[objectKey] = buffer.ToArray();
_contentTypes[objectKey] = contentType;
return Task.FromResult(objectKey);
}
/// <inheritdoc />
public Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
{
if (!_objects.TryGetValue(objectKey, out byte[]? content))
{
return Task.FromResult<Stream?>(null);
}
return Task.FromResult<Stream?>(new MemoryStream(content));
}
/// <inheritdoc />
public Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
{
if (!_objects.TryGetValue(objectKey, out byte[]? content))
{
return Task.FromResult<FileMeta?>(null);
}
string contentType = _contentTypes.TryGetValue(objectKey, out string? stored) ? stored : string.Empty;
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, content.Length, contentType));
}
/// <inheritdoc />
public Task DeleteAsync(string objectKey, CancellationToken ct)
{
_deletedKeys.Add(objectKey);
_objects.Remove(objectKey);
return Task.CompletedTask;
}
}
@@ -0,0 +1,80 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="IMlClient"/> для unit-тестов CardsService и воркера pump.
/// </summary>
public sealed class FakeMlClient : IMlClient
{
private readonly List<(string Text, string Label, double Delta)> _pushed = [];
/// <summary>
/// Ответ StatusAsync
/// </summary>
public MlStatusResponseDto Status { get; set; } = DefaultStatus();
/// <summary>
/// Ответ PredictAsync воркера pump
/// </summary>
public MlPredictResultDto? Predict { get; set; }
/// <summary>
/// Сколько раз вызван PredictAsync
/// </summary>
public int PredictCalls { get; private set; }
/// <summary>
/// Обучающие сигналы, отправленные через <see cref="PushAsync"/>
/// </summary>
public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList();
/// <inheritdoc />
public Task<MlStatusResponseDto> StatusAsync(CancellationToken ct) => Task.FromResult(Status);
/// <inheritdoc />
public Task PushAsync(
string text,
string label,
double delta,
CancellationToken ct)
{
_pushed.Add((text, label, delta));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
{
PredictCalls++;
return Predict is null
? throw new NotSupportedException()
: Task.FromResult(Predict);
}
/// <inheritdoc />
public Task<MlResetResultDto> ResetAsync(CancellationToken ct) =>
throw new NotSupportedException();
// Статус по умолчанию: модель не готова, все счётчики 0 (форма MlStatusResponseDto).
// Возвращает: Пустой статус (ml/ai/learning = 0), как у LocalMlClient до действий.
private static MlStatusResponseDto DefaultStatus()
{
var service = new MlServiceStatusDto(
Ready: false,
Classes: new Dictionary<string, double>(),
Learned: 0,
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
var stats = new MlStatsDto(
Ml: 0,
Ai: 0,
Learning: 0,
Ready: false,
Classes: new Dictionary<string, double>(),
Learned: 0,
Reachable: true,
Outbox: 0);
return new MlStatusResponseDto(Enabled: true, Service: service, Reachable: true, Stats: stats);
}
}
@@ -0,0 +1,189 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Grpc.Core;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="ITelegramGateway"/> для юнит-тестов модуля Telegram/ингресса.
/// </summary>
public sealed class FakeTelegramGateway : ITelegramGateway
{
// Фаза idle-формы (по умолчанию не подключён).
private const string IdlePhase = "idle";
private const string NotConnectedDetail = "Telegram не подключён";
/// <summary>
/// Вызовы SetMonitor
/// </summary>
public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = [];
/// <summary>
/// Вызовы SetMonitorAll
/// </summary>
public List<bool> SetMonitorAllCalls { get; } = [];
/// <summary>
/// Вызовы Backfill
/// </summary>
public List<(string DialogId, bool Force)> BackfillCalls { get; } = [];
/// <summary>
/// Вызовы ReadSource
/// </summary>
public List<(string DialogId, long MsgId)> ReadSourceCalls { get; } = [];
/// <summary>
/// Результат ReadSourceAsync
/// </summary>
public TelegramSourceContentDto SourceContent { get; set; } = new(false, null, null);
/// <summary>
/// Сколько сообщений «разобрал» Backfill
/// </summary>
public int BackfillResult { get; set; } = 3;
/// <summary>
/// Каталог, который возвращает RefreshDialogsAsync/SearchAsync
/// </summary>
public IReadOnlyList<TelegramDialogEntryDto> Catalog { get; set; } = [];
/// <summary>
/// Статус, который возвращает StatusAsync
/// </summary>
public TelegramAccountStatusDto Status { get; set; } =
new(IdlePhase, Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: null);
/// <summary>
/// Заданное исключение StatusAsync
/// </summary>
public RpcException? StatusFailure { get; set; }
/// <summary>
/// Диалоги, чей BackfillAsync падает
/// </summary>
public HashSet<string> BackfillFailures { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Диалоги, чей SetMonitorAsync падает
/// </summary>
public HashSet<string> SetMonitorFailures { get; } = new(StringComparer.Ordinal);
/// <inheritdoc />
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
{
if (StatusFailure is not null)
{
throw StatusFailure;
}
return Task.FromResult(Status);
}
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartPhoneAsync(
string phone,
int apiId,
string apiHash,
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto("code", null));
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto("qr", "https://t.me/qr/test"));
/// <inheritdoc />
public Task<string> SendCodeAsync(string code, CancellationToken ct) => Task.FromResult("ready");
/// <inheritdoc />
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult("ready");
/// <inheritdoc />
public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult(Catalog);
/// <inheritdoc />
public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{
if (SetMonitorFailures.Contains(dialogId))
{
throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
}
SetMonitorCalls.Add((dialogId, enabled));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct)
{
SetMonitorAllCalls.Add(enabled);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> BackfillAsync(
string dialogId,
bool force,
CancellationToken ct)
{
BackfillCalls.Add((dialogId, force));
if (BackfillFailures.Contains(dialogId))
{
throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
}
return Task.FromResult(BackfillResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
{
ReadSourceCalls.Add((dialogId, msgId));
return Task.FromResult(SourceContent);
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query,
int limit,
CancellationToken ct)
=> Task.FromResult(Catalog);
/// <inheritdoc />
public Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, "#666", null, false));
/// <inheritdoc />
public Task<TelegramEvalReadDto> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
/// <inheritdoc />
public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
}
@@ -0,0 +1,253 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Telegram.Application.Models;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="ITelegramStore"/> для юнит-тестов DialogsService/ингресса.
/// </summary>
public sealed class FakeTelegramStore : ITelegramStore
{
private readonly Dictionary<string, FakeTelegramDialogRow> _dialogs = new(StringComparer.Ordinal);
private readonly Dictionary<string, FakeTelegramMessageRow> _messages = new(StringComparer.Ordinal);
/// <summary>
/// Строки каталога фейка
/// </summary>
public IReadOnlyList<FakeTelegramDialogRow> Dialogs => _dialogs.Values.ToList();
/// <summary>
/// Строки превью фейка
/// </summary>
public IReadOnlyList<FakeTelegramMessageRow> Messages => _messages.Values.ToList();
/// <summary>
/// Кладёт строку каталога напрямую
/// </summary>
/// <param name="row">Строка как если бы была сохранена в БД.</param>
public void Seed(FakeTelegramDialogRow row)
{
_dialogs[row.Id] = row;
}
/// <summary>
/// Кладёт строку превью напрямую
/// </summary>
/// <param name="row">Строка как если бы была сохранена в БД.</param>
public void SeedMessage(FakeTelegramMessageRow row)
{
_messages[row.Id] = row;
}
/// <inheritdoc />
public Task<int> SyncFromTelegramAsync(
IReadOnlyCollection<TelegramDialogEntryDto> entries,
bool autoMonitorNew,
CancellationToken ct)
{
if (entries.Count == 0)
{
return Task.FromResult(0);
}
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (TelegramDialogEntryDto entry in entries)
{
if (_dialogs.TryGetValue(entry.Id, out FakeTelegramDialogRow? existing))
{
// Обновляются только метаданные; монитор пользователя не трогаем (как ON CONFLICT адаптера).
_dialogs[entry.Id] = existing with
{
Name = entry.Name,
Handle = entry.Handle,
Kind = entry.Kind,
Hue = entry.Hue,
UpdatedAt = now,
};
}
else
{
_dialogs[entry.Id] = new FakeTelegramDialogRow(
entry.Id, entry.Name, entry.Handle, entry.Kind, entry.Hue,
Monitor: autoMonitorNew, LastText: string.Empty, LastAt: null, Backfilled: false, UpdatedAt: now);
}
}
HashSet<string> seen = entries.Select(entry => entry.Id).ToHashSet(StringComparer.Ordinal);
foreach (string id in _dialogs.Keys.Where(id => !seen.Contains(id)).ToList())
{
_dialogs.Remove(id);
}
return Task.FromResult(entries.Count);
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogDto>> ListAsync(CancellationToken ct)
{
IReadOnlyList<TelegramDialogDto> items = _dialogs.Values
.OrderByDescending(row => row.Monitor)
.ThenBy(row => row.Name)
.Select(ToDialogDto)
.ToList();
return Task.FromResult(items);
}
/// <inheritdoc />
public Task<IReadOnlyCollection<string>> ListMonitoredIdsAsync(CancellationToken ct)
{
IReadOnlyCollection<string> ids = _dialogs.Values
.Where(row => row.Monitor)
.OrderBy(row => row.Id)
.Select(row => row.Id)
.ToList();
return Task.FromResult(ids);
}
/// <inheritdoc />
public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing))
{
_dialogs[dialogId] = existing with { Monitor = enabled, UpdatedAt = DateTimeOffset.UtcNow };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> SetMonitorAllAsync(bool enabled, CancellationToken ct)
{
int count = _dialogs.Count;
DateTimeOffset now = DateTimeOffset.UtcNow;
foreach (string id in _dialogs.Keys.ToList())
{
_dialogs[id] = _dialogs[id] with { Monitor = enabled, UpdatedAt = now };
}
return Task.FromResult(count);
}
/// <inheritdoc />
public Task<bool?> GetBackfilledAsync(string dialogId, CancellationToken ct)
{
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? row))
{
return Task.FromResult<bool?>(row.Backfilled);
}
return Task.FromResult<bool?>(null);
}
/// <inheritdoc />
public Task<IReadOnlyCollection<string>> ListNotBackfilledIdsAsync(CancellationToken ct)
{
IReadOnlyCollection<string> ids = _dialogs.Values
.Where(row => !row.Backfilled)
.OrderBy(row => row.Id)
.Select(row => row.Id)
.ToList();
return Task.FromResult(ids);
}
/// <inheritdoc />
public Task SetBackfilledAsync(string dialogId, CancellationToken ct)
{
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing))
{
_dialogs[dialogId] = existing with { Backfilled = true };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpsertDiscoveredMonitoredAsync(
string dialogId,
string name,
string handle,
string kind,
string hue,
CancellationToken ct)
{
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing))
{
_dialogs[dialogId] = existing with
{
Name = name,
Handle = handle,
Kind = kind,
Hue = hue,
Monitor = true,
Backfilled = false,
UpdatedAt = DateTimeOffset.UtcNow,
};
}
else
{
_dialogs[dialogId] = new FakeTelegramDialogRow(
dialogId, name, handle, kind, hue,
Monitor: true, LastText: string.Empty, LastAt: null, Backfilled: false, UpdatedAt: DateTimeOffset.UtcNow);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SavePreviewAsync(
string messageId,
string dialogId,
string text,
DateTimeOffset msgAt,
CancellationToken ct)
{
if (!_messages.ContainsKey(messageId))
{
_messages[messageId] = new FakeTelegramMessageRow(messageId, dialogId, text, msgAt, LeadId: null);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task TouchDialogLastAsync(
string dialogId,
string text,
DateTimeOffset at,
CancellationToken ct)
{
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing))
{
_dialogs[dialogId] = existing with { LastText = text, LastAt = at, UpdatedAt = at };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramMessageDto>> ListMessagesAsync(
string dialogId,
int limit,
CancellationToken ct)
{
IReadOnlyList<TelegramMessageDto> items = _messages.Values
.Where(row => row.DialogId == dialogId)
.OrderByDescending(row => row.MsgAt)
.Take(limit)
.Select(row => new TelegramMessageDto(row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null))
.ToList();
return Task.FromResult(items);
}
private static TelegramDialogDto ToDialogDto(FakeTelegramDialogRow row)
{
long? lastAtMs = row.LastAt?.ToUnixTimeMilliseconds();
TelegramDialogLastDto? last = lastAtMs is null ? null : new TelegramDialogLastDto(row.LastText, lastAtMs);
return new TelegramDialogDto(row.Id, row.Name, row.Handle, row.Kind, row.Hue, row.Monitor, last);
}
}
@@ -0,0 +1,144 @@
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Exceptions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты gRPC-адаптера IAiTools к ai-service
/// </summary>
[Collection("MlGrpcTests")]
public sealed class GrpcAiToolsTests
{
// Id тенанта сценариев строкой (формат N) — ожидаемое значение metadata tenant-id.
private const string TenantIdValue = "fedcba9876543210fedcba9876543210";
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
[Fact]
public async Task GenerateKeywordsAsync_MapsKeywordsAndSendsProviderConfigAndMetadata()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
var reply = new GenerateKeywordsReply { Usage = new Usage { Prompt = 300, Completion = 60, Total = 360 } };
reply.Keywords.Add("python разработка");
reply.Keywords.Add("telegram каналы");
service.GenerateKeywordsReply = reply;
FakeSettingsStore settings = new();
FakeSecretCipher cipher = new();
FakeTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, settings, cipher, limits);
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal(new[] { "python разработка", "telegram каналы" }, result.Keywords);
Assert.Null(result.Error);
Assert.NotNull(service.LastKeywords);
Assert.Equal("Бэкенд-разработка на Python", service.LastKeywords!.Description);
Assert.Equal("deepseek", service.LastKeywords.ProviderConfig.ProviderId);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens));
Assert.Equal("{\"prompt\":300,\"completion\":60,\"total\":360}", settings.GetStoredJson(SettingsKeys.AiTokenUsage));
Assert.Equal(360, limits.UsedTokens(TenantGuid));
});
}
[Fact]
public async Task GenerateKeywordsAsync_Unavailable_ReturnsSoftErrorWithKeywordsEmpty()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.KeywordsUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher());
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
Assert.False(result.Ok);
Assert.Empty(result.Keywords);
Assert.NotNull(result.Error);
Assert.Contains("не ответил корректно", result.Error);
});
}
[Fact]
public async Task EvaluateFitAsync_MapsReplyAndSendsTaskFields()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.EvaluateFitReply = new EvaluateFitReply
{
Fit = false,
Reason = "другая сфера",
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
};
FakeTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher(), limits);
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
Assert.False(result.Fit);
Assert.Equal("другая сфера", result.Reason);
Assert.NotNull(service.LastFit);
Assert.Equal("Ищу дизайнера для лендинга", service.LastFit!.Text);
Assert.Equal("Разработка сайтов на Python", service.LastFit.Description);
Assert.Equal(new[] { "python", "бэкенд" }, service.LastFit.Keywords);
Assert.Equal("deepseek", service.LastFit.ProviderConfig.ProviderId);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
Assert.Equal(AiGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens));
Assert.Equal(210, limits.UsedTokens(TenantGuid));
});
}
[Fact]
public async Task EvaluateFitAsync_Unavailable_ThrowsAiUnavailable()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FitUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher());
await Assert.ThrowsAsync<AiUnavailableException>(
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
});
}
// Создаёт GrpcAiTools к хосту-фейку на эфемерном порту в tenant-контексте теста.
// port: Порт хоста-фейка ai-service.
// settings: KV-хранилище тенанта (дефолты при пустом).
// cipher: Шифр секретов (расшифровка apiKey).
// limits: Фейк-хранилище лимитов (списание usage; при null — собственный экземпляр).
// Возвращает: Экземпляр GrpcAiTools.
private static GrpcAiTools CreateTools(
int port,
FakeSettingsStore settings,
FakeSecretCipher cipher,
FakeTenantLimitStore? limits = null)
{
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
return new GrpcAiTools(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, cipher),
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())),
NullLogger<GrpcAiTools>.Instance);
}
}
@@ -0,0 +1,332 @@
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ml;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты gRPC-адаптера IMlClient/IMlTrainClient к ml-service.
/// </summary>
[Collection("MlGrpcTests")]
public sealed class GrpcMlClientTests
{
// Идентификатор тенанта сценариев (формат N — ключ пула модели ml-service).
private static readonly Guid Tenant = Guid.NewGuid();
// Id тенанта строкой (формат N) — ожидаемое значение metadata tenant-id.
private static readonly string TenantIdValue = Tenant.ToString("N");
// ─── PredictAsync: маппинг и фолбэк ─────────────────────────────────────
[Fact]
public async Task PredictAsync_MapsFullReply_ToContractDtoAndSendsMetadata()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.PredictReply = new PredictReply
{
Take = true,
Label = "b_junior",
Scores = { { "b_junior", 0.95 }, { "b_senior", 0.3 } },
Hits = 3,
Ready = true,
Margin = 0.9,
Terms = { "python", "middle" },
Type = new TypeDecision { Take = true, Label = "hire", Value = "t:hire", Margin = 0.5 },
};
IMlClient client = CreateClient(port);
MlPredictResultDto result = await client.PredictAsync("нужен middle python разработчик", CancellationToken.None);
Assert.True(result.Take);
Assert.Equal("b_junior", result.Label);
Assert.Equal(0.95, result.Scores["b_junior"]);
Assert.Equal(0.3, result.Scores["b_senior"]);
Assert.Equal(3, result.Hits);
Assert.True(result.Ready);
Assert.Equal(0.9, result.Margin);
Assert.Equal(new[] { "python", "middle" }, result.Terms);
Assert.NotNull(result.Type);
Assert.True(result.Type.Take);
Assert.Equal("hire", result.Type.Label);
Assert.Equal("t:hire", result.Type.Value);
Assert.Equal(0.5, result.Type.Margin);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
Assert.Equal(MlGrpcTestHost.DefaultToken, Assert.Single(service.RequestTokens));
});
}
[Fact]
public async Task PredictAsync_ServiceUnavailable_ReturnsNotReadyPrediction()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.PredictUnavailable = true;
IMlClient client = CreateClient(port);
MlPredictResultDto result = await client.PredictAsync("текст", CancellationToken.None);
Assert.False(result.Take);
Assert.Null(result.Label);
Assert.Empty(result.Scores);
Assert.Equal(0, result.Hits);
Assert.False(result.Ready);
Assert.Null(result.Margin);
Assert.Empty(result.Terms);
Assert.Null(result.Type);
Assert.Single(service.RequestTenantIds);
});
}
// ─── StatusAsync: статус сервиса + кэш 15 с + reachable ─────────────────
[Fact]
public async Task StatusAsync_FetchesServiceStatusAndMergesLocalStats()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.StatusReply = new StatusReply
{
Ready = true,
Classes = { { "b_junior", 12.0 }, { "spam", 5.0 } },
Learned = 17,
Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 },
};
var settings = new FakeSettingsStore();
settings.Preload(SettingsKeys.MlDecisions, "7");
settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new FakeMlLearningStore { LearningCount = 9 };
IMlClient client = CreateClient(port, settings, learning);
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
Assert.True(status.Enabled); // mlEnabled не задан — дефолт true
Assert.True(status.Reachable);
Assert.True(status.Service.Ready);
Assert.Equal(17, status.Service.Learned);
Assert.Equal(0.8, status.Service.Eval.Accuracy);
Assert.Equal(7, status.Stats.Ml);
Assert.Equal(3, status.Stats.Ai);
Assert.Equal(9, status.Stats.Learning);
Assert.Equal(0, status.Stats.Outbox);
Assert.True(status.Stats.Ready);
Assert.Equal(17, status.Stats.Learned);
Assert.True(status.Stats.Reachable);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
});
}
[Fact]
public async Task StatusAsync_ServiceDown_ServesCachedDataWithReachableFalse_ThenRecoversAfterTtl()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
DateTimeOffset now = DateTimeOffset.UtcNow;
var cache = new MlStatusCache(() => now);
IMlClient client = CreateClient(port, cache: cache);
service.StatusUnavailable = true;
MlStatusResponseDto down = await client.StatusAsync(CancellationToken.None);
Assert.False(down.Reachable);
Assert.False(down.Service.Ready);
Assert.False(down.Stats.Reachable);
Assert.Equal(1, service.StatusCalls);
// «Поднялся»: после TTL 15 с следующий StatusAsync обновляет кэш (ready=true, reachable=true).
service.StatusUnavailable = false;
service.StatusReply = new StatusReply
{
Ready = true,
Classes = { { "b_x", 1.0 } },
Learned = 3,
Eval = new ModelEval { Count = 2, Correct = 2, Accuracy = 1.0 },
};
now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1);
MlStatusResponseDto up = await client.StatusAsync(CancellationToken.None);
Assert.True(up.Reachable);
Assert.True(up.Service.Ready);
Assert.Equal(3, up.Service.Learned);
Assert.Equal(2, service.StatusCalls);
});
}
[Fact]
public async Task StatusAsync_CachesWithinTtl_SingleFetch()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
DateTimeOffset now = DateTimeOffset.UtcNow;
IMlClient client = CreateClient(port, cache: new MlStatusCache(() => now));
_ = await client.StatusAsync(CancellationToken.None);
now = now.AddSeconds(5); // в пределах TTL 15 с — повторный вызов не ходит в сервис
_ = await client.StatusAsync(CancellationToken.None);
Assert.Equal(1, service.StatusCalls);
now = now.AddSeconds(MlStatusCache.CacheTtlSeconds + 1); // TTL истёк — новый fetch
_ = await client.StatusAsync(CancellationToken.None);
Assert.Equal(2, service.StatusCalls);
});
}
// ─── ResetAsync: gRPC Reset + очистка outbox только при успехе ──────────
[Fact]
public async Task ResetAsync_ServiceOk_ClearsOutboxAndInvalidatesStatusCache()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var learning = new FakeMlLearningStore();
learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0);
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
// Прогреть кэш статуса (до сброса — 1 вызов Status), затем сброс.
_ = await client.StatusAsync(CancellationToken.None);
MlResetResultDto reset = await client.ResetAsync(CancellationToken.None);
Assert.True(reset.Ok);
Assert.Null(reset.Error);
Assert.Equal(0, await learning.CountOutboxAsync(CancellationToken.None)); // очередь очищена
Assert.Equal(1, service.ResetCalls);
_ = await client.StatusAsync(CancellationToken.None);
Assert.Equal(2, service.StatusCalls);
});
}
[Fact]
public async Task ResetAsync_ServiceSoftError_ReturnsOkFalseAndKeepsOutbox()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
var learning = new FakeMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
IMlClient client = CreateClient(port, learning: learning);
MlResetResultDto reset = await client.ResetAsync(CancellationToken.None);
Assert.False(reset.Ok);
Assert.Equal("не удалось пересоздать файл модели", reset.Error);
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
});
}
[Fact]
public async Task ResetAsync_ServiceUnavailable_ReturnsOkFalseAndKeepsOutbox()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetUnavailable = true;
var learning = new FakeMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
MlResetResultDto reset = await client.ResetAsync(CancellationToken.None);
Assert.False(reset.Ok);
Assert.Equal("ML-сервис недоступен", reset.Error);
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
});
}
[Fact]
public async Task PushAsync_WritesOutboxRow()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var learning = new FakeMlLearningStore();
IMlClient client = CreateClient(port, learning: learning);
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
var row = Assert.Single(learning.AddedRows);
Assert.StartsWith("mle_", row.Id, StringComparison.Ordinal);
Assert.Equal("нужен python", row.Text); // trim
Assert.Equal("b_junior", row.Label);
Assert.Equal(1.0, row.Delta);
Assert.Empty(service.RequestTenantIds); // push сервис не зовёт — только локальная запись
});
}
// ─── TrainBatchAsync (IMlTrainClient): батч для флашера ─────────────────
[Fact]
public async Task TrainBatchAsync_SendsItemsAndReturnsLearned()
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
IMlClient client = CreateClient(port);
var items = new[]
{
new MlOutboxEntryDto("mle_1", "текст 1", "b_a", 1.0),
new MlOutboxEntryDto("mle_2", "текст 2", "spam", -1.0),
};
int learned = await ((IMlTrainClient)client).TrainBatchAsync(items, CancellationToken.None);
Assert.Equal(2, learned);
TrainBatchRequest batch = Assert.Single(service.TrainBatches);
Assert.Equal(2, batch.Items.Count);
Assert.Equal("текст 1", batch.Items[0].Text);
Assert.Equal("b_a", batch.Items[0].Label);
Assert.Equal(1.0, batch.Items[0].Delta);
Assert.Equal("текст 2", batch.Items[1].Text);
Assert.Equal("spam", batch.Items[1].Label);
Assert.Equal(-1.0, batch.Items[1].Delta);
Assert.Equal(TenantIdValue, Assert.Single(service.RequestTenantIds));
});
}
// ─── Хелперы ────────────────────────────────────────────────────────────
// Создаёт GrpcMlClient к хосту-фейку на эфемерном порту с пустыми фейками статистики.
// port: Порт хоста-фейка ml-service.
// settings: KV-хранилище тенанта (пустое — дефолты).
// learning: Хранилище обучения (пустое).
// cache: Кэш статуса (по умолчанию реальный — UtcNow).
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
private static GrpcMlClient CreateClient(
int port,
FakeSettingsStore? settings = null,
FakeMlLearningStore? learning = null,
MlStatusCache? cache = null)
{
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
return new GrpcMlClient(
tenantContext,
settings ?? new FakeSettingsStore(),
learning ?? new FakeMlLearningStore(),
new MlGrpcConnection(options),
cache ?? new MlStatusCache(),
new TokenUsageRecorder(
settings ?? new FakeSettingsStore(),
new FakeTenantLimitStore(),
tenantContext,
new TokenUsageEventService(new FakeTokenUsageEventStore())),
NullLogger<GrpcMlClient>.Instance);
}
}
@@ -0,0 +1,260 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Telegram;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Support;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-proc тесты gRPC-адаптера гейта GrpcTelegramClient «по проводу».
/// </summary>
[Collection("MlGrpcTests")]
public sealed class GrpcTelegramClientTests
{
/// <summary>
/// StatusAsync маппит live-поля telegram-service
/// </summary>
[Fact]
public async Task StatusAsync_MapsLiveStatusFields()
{
var service = new RecordingTelegramService
{
StatusPhase = "ready",
StatusConnected = true,
StatusListener = true,
StatusAccount = "@user",
StatusError = "text",
};
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) =>
{
ITelegramGateway gateway = CreateClient(port);
TelegramAccountStatusDto status = await gateway.StatusAsync(CancellationToken.None);
Assert.Equal("ready", status.Phase);
Assert.True(status.Connected);
Assert.True(status.Listener);
Assert.Equal("@user", status.Account);
Assert.Equal("text", status.Error);
});
}
/// <summary>
/// Доменный RPC-отказ сервиса пробрасывается как есть
/// </summary>
[Fact]
public async Task StatusAsync_ServiceDomainFailure_PropagatesRpcExceptionWithDetail()
{
var service = new RecordingTelegramService
{
DomainFailure = new RpcException(new Status(StatusCode.FailedPrecondition, "Telegram не подключён")),
};
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) =>
{
ITelegramGateway gateway = CreateClient(port);
RpcException failure = await Assert.ThrowsAsync<RpcException>(() => gateway.StatusAsync(CancellationToken.None));
Assert.Equal(StatusCode.FailedPrecondition, failure.StatusCode);
Assert.Equal("Telegram не подключён", failure.Status.Detail);
});
}
/// <summary>
/// StartPhone уходит с phone/api_id/api_hash и возвращает фазу
/// </summary>
[Fact]
public async Task StartPhoneAsync_SendsPhoneAndKeysAndReturnsPhase()
{
var service = new RecordingTelegramService();
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) =>
{
ITelegramGateway gateway = CreateClient(port);
TelegramAuthResultDto result = await gateway.StartPhoneAsync("+79001234567", 123456, "hash", CancellationToken.None);
Assert.Equal("code", result.Phase);
Assert.NotNull(recorded.LastStartPhone);
Assert.Equal("+79001234567", recorded.LastStartPhone.Phone);
Assert.Equal(123456, recorded.LastStartPhone.ApiId);
Assert.Equal("hash", recorded.LastStartPhone.ApiHash);
});
}
/// <summary>
/// StartQr уходит с ключами и возвращает фазу + qrUrl
/// </summary>
[Fact]
public async Task StartQrAsync_SendsKeysAndReturnsPhaseWithQrUrl()
{
var service = new RecordingTelegramService { QrUrl = "https://t.me/qr/abc" };
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) =>
{
ITelegramGateway gateway = CreateClient(port);
TelegramAuthResultDto result = await gateway.StartQrAsync(123456, "hash", CancellationToken.None);
Assert.Equal("qr", result.Phase);
Assert.Equal("https://t.me/qr/abc", result.QrUrl);
Assert.NotNull(recorded.LastStartQr);
Assert.Equal(123456, recorded.LastStartQr.ApiId);
Assert.Equal("hash", recorded.LastStartQr.ApiHash);
});
}
/// <summary>
/// RefreshDialogs маппит каталог.
/// </summary>
[Fact]
public async Task RefreshDialogsAsync_MapsCatalogEntries()
{
var service = new RecordingTelegramService();
service.Catalog.Add(new DialogEntry { Id = "-1001", Name = "Канал", Username = "ch", Kind = "channel", Hue = "#a33" });
service.Catalog.Add(new DialogEntry { Id = "-2", Name = "Группа", Username = string.Empty, Kind = "group", Hue = "#b44" });
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) =>
{
ITelegramGateway gateway = CreateClient(port);
IReadOnlyList<TelegramDialogEntryDto> entries = await gateway.RefreshDialogsAsync(CancellationToken.None);
Assert.Equal(2, entries.Count);
Assert.Equal(new TelegramDialogEntryDto("-1001", "Канал", "ch", "channel", "#a33"), entries[0]);
Assert.Equal(new TelegramDialogEntryDto("-2", "Группа", string.Empty, "group", "#b44"), entries[1]);
});
}
/// <summary>
/// Backfill уходит с dialog_id/force и возвращает processed
/// </summary>
[Fact]
public async Task BackfillAsync_SendsDialogAndForceAndReturnsProcessed()
{
var service = new RecordingTelegramService { BackfillProcessed = 7 };
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) =>
{
ITelegramGateway gateway = CreateClient(port);
int processed = await gateway.BackfillAsync("-1001", force: true, CancellationToken.None);
Assert.Equal(7, processed);
BackfillRequest request = Assert.Single(recorded.BackfillRequests);
Assert.Equal("-1001", request.DialogId);
Assert.True(request.Force);
});
}
/// <summary>
/// ReadRecent уходит с dialog_id/limit и маппит сообщения превью
/// </summary>
[Fact]
public async Task ReadRecentAsync_SendsDialogAndLimitAndMapsMessages()
{
var service = new RecordingTelegramService();
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) =>
{
ITelegramGateway gateway = CreateClient(port);
IReadOnlyList<TelegramRecentMessageDto> messages = await gateway.ReadRecentAsync("-1001", 24, CancellationToken.None);
ReadRecentRequest request = Assert.Single(recorded.ReadRecentRequests);
Assert.Equal("-1001", request.DialogId);
Assert.Equal(24, request.Limit);
TelegramRecentMessageDto message = Assert.Single(messages);
Assert.Equal("100500", message.Id);
Assert.Equal("Свежее", message.Text);
Assert.Equal(1700000000000, message.TimeMs);
});
}
/// <summary>
/// ReadSource уходит с dialog_id/msg_id и маппит текст/время
/// </summary>
[Fact]
public async Task ReadSourceAsync_SendsDialogAndMsgIdAndMapsText()
{
var service = new RecordingTelegramService
{
ReadSourceFound = true,
ReadSourceText = "Исходник",
ReadSourceTime = 1700000000000,
};
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, recorded) =>
{
ITelegramGateway gateway = CreateClient(port);
TelegramSourceContentDto content = await gateway.ReadSourceAsync("-1001", 777, CancellationToken.None);
ReadSourceRequest request = Assert.Single(recorded.ReadSourceRequests);
Assert.Equal("-1001", request.DialogId);
Assert.Equal(777, request.MsgId);
Assert.True(content.Found);
Assert.Equal("Исходник", content.Text);
Assert.Equal(1700000000000, content.TimeMs);
});
}
/// <summary>
/// ReadSource с found=false не отдаёт текст и время
/// </summary>
[Fact]
public async Task ReadSourceAsync_NotFound_MapsEmptyContent()
{
var service = new RecordingTelegramService { ReadSourceFound = false };
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, service, async (port, _) =>
{
ITelegramGateway gateway = CreateClient(port);
TelegramSourceContentDto content = await gateway.ReadSourceAsync("-1001", 777, CancellationToken.None);
Assert.False(content.Found);
Assert.Null(content.Text);
Assert.Null(content.TimeMs);
});
}
/// <summary>
/// Вызов вне tenant-контекста — ошибка конфигурации.
/// </summary>
[Fact]
public async Task Call_WithoutTenantContext_ThrowsInvalidOperation()
{
await TelegramGrpcTestHost.RunAsync(TelegramGrpcTestHost.DefaultToken, new RecordingTelegramService(), async (port, _) =>
{
var connection = new TelegramGrpcConnection(new TelegramServiceOptions
{
UseLocal = false,
Endpoint = $"http://127.0.0.1:{port}",
});
using (connection)
{
ITelegramGateway gateway = new GrpcTelegramClient(
new TenantContext(), connection, NullLogger<GrpcTelegramClient>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.StatusAsync(CancellationToken.None));
}
});
}
// Собирает гейт-клиент сценария на tenant-контексте теста и канале к фейк-сервису.
// port: Порт фейк-telegram-service (host сценария).
// Возвращает: Гейт (транспорт живёт на время сценария).
private static GrpcTelegramClient CreateClient(int port)
{
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
var connection = new TelegramGrpcConnection(new TelegramServiceOptions
{
UseLocal = false,
Endpoint = $"http://127.0.0.1:{port}",
});
return new GrpcTelegramClient(tenantContext, connection, NullLogger<GrpcTelegramClient>.Instance);
}
}
@@ -0,0 +1,159 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Abstractions;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.DependencyInjection;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты выбора реализации портов интеграций по флагам Services:Ml|Ai|Telegram:UseLocal
/// </summary>
[Collection("MlGrpcTests")]
public sealed class IntegrationsDiTests
{
[Fact]
public async Task AddDealIntegrations_UseLocalTrue_RegistersLocalClientsWithoutGrpcConnections()
{
await using ServiceProvider provider = BuildProvider(
new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = true },
new TelegramServiceOptions { UseLocal = true });
await using AsyncServiceScope scope = provider.CreateAsyncScope();
// Фолбэк по умолчанию: Local-заглушки; gRPC-транспортов и IMlTrainClient (для флашера) нет.
Assert.IsType<LocalMlClient>(scope.ServiceProvider.GetRequiredService<IMlClient>());
Assert.Null(scope.ServiceProvider.GetService<IMlTrainClient>());
Assert.IsType<LocalAiClassifier>(scope.ServiceProvider.GetRequiredService<IAiClassifier>());
Assert.IsType<LocalAiTools>(scope.ServiceProvider.GetRequiredService<IAiTools>());
Assert.IsType<LocalTelegramGateway>(scope.ServiceProvider.GetRequiredService<ITelegramGateway>());
Assert.Null(scope.ServiceProvider.GetService<AiGrpcConnection>());
Assert.Null(scope.ServiceProvider.GetService<TelegramGrpcConnection>());
}
[Fact]
public async Task AddDealIntegrations_UseLocalFalse_RegistersGrpcClientsUnderBothPorts()
{
string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey);
Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, MlGrpcTestHost.DefaultToken);
try
{
await using ServiceProvider provider = BuildProvider(
new MlServiceOptions { UseLocal = false }, new AiServiceOptions { UseLocal = false },
new TelegramServiceOptions { UseLocal = false });
await using AsyncServiceScope scope = provider.CreateAsyncScope();
IMlClient mlClient = scope.ServiceProvider.GetRequiredService<IMlClient>();
IMlTrainClient trainClient = scope.ServiceProvider.GetRequiredService<IMlTrainClient>();
Assert.IsType<GrpcMlClient>(mlClient);
Assert.Same(mlClient, trainClient);
Assert.IsType<BudgetedAiClassifier>(scope.ServiceProvider.GetRequiredService<IAiClassifier>());
Assert.IsType<BudgetedAiTools>(scope.ServiceProvider.GetRequiredService<IAiTools>());
// Внутренние платные адаптеры остаются разрешимыми (декораторы построены поверх них).
Assert.IsType<GrpcAiClassifier>(scope.ServiceProvider.GetRequiredService<GrpcAiClassifier>());
Assert.IsType<GrpcAiTools>(scope.ServiceProvider.GetRequiredService<GrpcAiTools>());
Assert.IsType<AiGrpcConnection>(scope.ServiceProvider.GetRequiredService<AiGrpcConnection>());
Assert.IsType<MlGrpcConnection>(scope.ServiceProvider.GetRequiredService<MlGrpcConnection>());
Assert.IsType<GrpcTelegramClient>(scope.ServiceProvider.GetRequiredService<ITelegramGateway>());
Assert.IsType<TelegramGrpcConnection>(scope.ServiceProvider.GetRequiredService<TelegramGrpcConnection>());
}
finally
{
Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken);
}
}
[Fact]
public void AddDealIntegrations_UseLocalFalseMl_WithoutServiceToken_ThrowsAtStartup()
{
string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey);
Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null);
try
{
Assert.Throws<InvalidOperationException>(() => BuildProvider(
new MlServiceOptions { UseLocal = false }, new AiServiceOptions { UseLocal = true },
new TelegramServiceOptions { UseLocal = true }));
}
finally
{
Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken);
}
}
[Fact]
public void AddDealIntegrations_UseLocalFalseAi_WithoutServiceToken_ThrowsAtStartup()
{
string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey);
Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null);
try
{
Assert.Throws<InvalidOperationException>(() => BuildProvider(
new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = false },
new TelegramServiceOptions { UseLocal = true }));
}
finally
{
Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken);
}
}
[Fact]
public void AddDealIntegrations_UseLocalFalseTelegram_WithoutServiceToken_ThrowsAtStartup()
{
string? originalToken = Environment.GetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey);
Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, null);
try
{
Assert.Throws<InvalidOperationException>(() => BuildProvider(
new MlServiceOptions { UseLocal = true }, new AiServiceOptions { UseLocal = true },
new TelegramServiceOptions { UseLocal = false }));
}
finally
{
Environment.SetEnvironmentVariable(MlGrpcConnection.ServiceTokenEnvKey, originalToken);
}
}
// Строит провайдер теста: AddDealIntegrations(mlOptions, aiOptions, telegramOptions) поверх tenant-фейков
// (как Program.cs; AiClassifyContextBuilder регистрирует AddPipelineModule).
// mlOptions: Конфигурация секции Services:Ml сценария.
// aiOptions: Конфигурация секции Services:Ai сценария.
// telegramOptions: Конфигурация секции Services:Telegram сценария.
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
MlServiceOptions mlOptions,
AiServiceOptions aiOptions,
TelegramServiceOptions telegramOptions)
{
var services = new ServiceCollection();
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
services.AddScoped<ISecretCipher>(_ => new FakeSecretCipher());
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new FakeTokenUsageEventStore()));
services.AddScoped<LocalFieldsParser>();
services.AddScoped<AiClassifyContextBuilder>();
services.AddLogging();
services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions);
return services.BuildServiceProvider();
}
}
@@ -0,0 +1,153 @@
using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты детерминированного ИИ-классификатора <see cref="LocalAiClassifier"/>.
/// </summary>
public sealed class LocalAiClassifierTests
{
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
// Возвращает: Кортеж (KV-настройки, классификатор).
private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
{
var settings = new FakeSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings));
return (settings, classifier);
}
// ─── ИИ-фильтр: локально всегда пропуск ─────────────────────────────────
[Fact]
public async Task FilterAsync_AnyText_AlwaysPassesWithSkipped()
{
(_, LocalAiClassifier classifier) = CreateContext();
AiFilterResultDto result = await classifier.FilterAsync("Срочно ищем Java-разработчика, оплата 2000$", default);
Assert.True(result.Pass);
Assert.Null(result.Reason);
Assert.True(result.Skipped);
}
// ─── Классификация: вакансия с бюджетом/контактами ──────────────────────
[Fact]
public async Task Classify_VacancyWithBudgetAndContacts_ProducesStructuredDto()
{
(_, LocalAiClassifier classifier) = CreateContext();
const string text =
"Вакансия: Middle Java-разработчик в команду\n"
+ "Стек: Java, C#, Kotlin\n"
+ "Контакты: @dev_ivan, @some_bot, dev@q.ru\n"
+ "Бюджет: 15002000 $\n"
+ "Работа над внутренним продуктом компании, удалённая команда из пяти человек";
AiParsedCardDto result = await classifier.ClassifyAsync(text, default);
Assert.Equal("Вакансия: Middle Java-разработчик в команду", result.Title);
Assert.Equal("Работа над внутренним продуктом компании, удалённая команда из пяти человек", result.Summary);
Assert.Equal(new[] { "Java", "C#", "Kotlin" }, result.Stack);
Assert.Equal(new AiBudgetDto(1500, 2000, "USD"), result.Budget);
// Контакты квалифицированы: @some_bot отброшен (бот), остались tg и email.
Assert.Equal(
new[] { new AiContactDto("tg", "@dev_ivan"), new AiContactDto("email", "dev@q.ru") },
result.Contacts);
Assert.True(result.IsVacancy); // маркерная гипотеза «вакансия»/«в команду»
Assert.False(result.IsVacancyKnown); // тип подтверждает только ИИ по контексту (python L796)
Assert.False(result.IsSpam);
Assert.Null(result.Board); // смысловые колонки до ИИ не назначаем (python L797)
}
// ─── Классификация: бюджет «до X» и валюта-суффикс ──────────────────────
[Fact]
public async Task Classify_BudgetUpToWithKSuffix_NormalizesBudget()
{
(_, LocalAiClassifier classifier) = CreateContext();
const string text =
"Ищем Python-разработчика на проект в команду\n"
+ "Бюджет: до 2к$";
AiParsedCardDto result = await classifier.ClassifyAsync(text, default);
Assert.Equal(new AiBudgetDto(From: null, To: 2000, Cur: "USD"), result.Budget);
}
// ─── Классификация: заказ без бюджета — не вакансия ─────────────────────
[Fact]
public async Task Classify_OrderWithoutBudget_IsNotVacancyAndBudgetNull()
{
(_, LocalAiClassifier classifier) = CreateContext();
const string text =
"Нужно сделать лендинг для кофейни: дизайн в Figma и вёрстка, срок — две недели.\n"
+ "Портфолио присылайте в личку.";
AiParsedCardDto result = await classifier.ClassifyAsync(text, default);
Assert.False(result.IsVacancy); // маркеров найма нет — разовая сделка
Assert.False(result.IsVacancyKnown);
Assert.Null(result.Budget); // суммы с валютой в тексте нет
Assert.Empty(result.Contacts); // @username/email/телефона в тексте нет
Assert.Empty(result.Stack);
Assert.Null(result.Board);
Assert.False(result.IsSpam);
// Блок «О заявке» локального пути: legacy-суть из содержательных строк без структурированных полей.
Assert.Null(result.Company);
Assert.Null(result.Format);
Assert.Null(result.Task);
Assert.Null(result.Requirements);
Assert.Null(result.Plus);
Assert.Null(result.Conditions);
Assert.False(string.IsNullOrWhiteSpace(result.Summary));
}
// ─── Детерминизм ────────────────────────────────────────────────────────
[Fact]
public async Task Classify_SameTextTwice_ReturnsIdenticalDto()
{
(_, LocalAiClassifier classifier) = CreateContext();
const string text =
"Вакансия: Middle Java-разработчик в команду\n"
+ "Стек: Java, C#, Kotlin\n"
+ "Контакты: @dev_ivan, dev@q.ru\n"
+ "Бюджет: 15002000 $\n"
+ "Работа над внутренним продуктом компании, удалённая команда из пяти человек";
AiParsedCardDto first = await classifier.ClassifyAsync(text, default);
AiParsedCardDto second = await classifier.ClassifyAsync(text, default);
// Классификатор детерминирован: одинаковый текст → одинаковый разбор (поля, бюджет, контакты, стек).
Assert.Equal(first.Title, second.Title);
Assert.Equal(first.Summary, second.Summary);
Assert.Equal(first.IsVacancy, second.IsVacancy);
Assert.Equal(first.IsVacancyKnown, second.IsVacancyKnown);
Assert.Equal(first.IsSpam, second.IsSpam);
Assert.Equal(first.Board, second.Board);
Assert.Equal(first.Budget, second.Budget);
Assert.Equal(first.Stack, second.Stack);
Assert.Equal(first.Contacts, second.Contacts);
}
// ─── Маркеры найма из настроек тенанта ──────────────────────────────────
[Fact]
public async Task Classify_CustomHireMarkersOverride_AreUsed()
{
(FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
AiParsedCardDto withMarker = await classifier.ClassifyAsync("Срочно нанимаем джунов на проект", default);
AiParsedCardDto withoutMarker = await classifier.ClassifyAsync("Вакансия Java-разработчика на проект", default);
Assert.True(withMarker.IsVacancy);
Assert.False(withoutMarker.IsVacancy);
}
}
@@ -0,0 +1,27 @@
using Deal.Infrastructure.Integrations.Services;
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты Local-реализации порта ИИ-инструментов <see cref="LocalAiTools"/>
/// </summary>
public sealed class LocalAiToolsTests
{
private readonly IAiTools _tools = new LocalAiTools();
[Fact]
public async Task GenerateKeywordsAsync_AlwaysThrowsNotSupported()
{
// Исключение — явный сигнал «вызов порта в Local-режиме — ошибка сценария» (не молча пустые ключи).
await Assert.ThrowsAsync<NotSupportedException>(
() => _tools.GenerateKeywordsAsync("описание ниши", CancellationToken.None));
}
[Fact]
public async Task EvaluateFitAsync_AlwaysThrowsNotSupported()
{
await Assert.ThrowsAsync<NotSupportedException>(
() => _tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
}
}
@@ -0,0 +1,120 @@
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Тесты обёртки создания карточки PipelineCardWriter.
/// </summary>
public sealed class PipelineCardWriterTests
{
[Fact]
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
AiParsedCardDto parsed = Parsed(
title: "Python-разработчик на бота",
budget: new AiBudgetDto(2000, 2000, "USD"),
contacts: new[] { new AiContactDto("tg", "@ivan_py") },
isVacancy: true);
CardDto card = await writer.CreateCardAsync(parsed, Message("Ищем Python-разработчика, 2000$"), hash, CancellationToken.None);
// Карточка создана через публичный интерфейс Kanban (l_, «Неразобранное») и перечитана (SSE-форма).
Assert.StartsWith(KanbanIdPrefixes.Card, card.Id);
Assert.Equal(KanbanColumns.Inbox, card.Col);
Assert.True(card.IsNew);
Assert.Equal("Python-разработчик на бота", card.Title);
Assert.Same(card, Assert.Single(store.CardDtos)); // чтение после записи — та же строка фейка
Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), card.Budget);
Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash));
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None));
}
[Fact]
public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
CardDto card = await writer.CreateCardAsync(
Parsed(title: "Python-разработчик", board: "b_py"),
Message("Ищем Python-разработчика в команду"),
hash,
CancellationToken.None);
// Доска без правил приняла текст (ContainerAccepts) — карточка легла в колонку доски, линк сделан.
Assert.Equal("b_py", card.Col);
Assert.Equal("b_py", Assert.Single(store.CardDtos).Col);
Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash));
}
// ─── Ошибки: сбой записи карточки ────────────────────────────────────────────────────
[Fact]
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>(
() => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None));
// Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null).
Assert.Empty(store.CardDtos);
Assert.Null(pipelineStore.DedupLeadId(hash));
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
}
// ─── Хелперы ──────────────────────────────────────────────────────────────────────────
// Создаёт контекст теста: фейки канбана, пайплайна и композитор поверх них.
// Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна).
private static (PipelineCardWriter Writer, FakeKanjStore Store, FakePipelineStore PipelineStore) Create()
{
var store = new FakeKanjStore();
var pipelineStore = new FakePipelineStore();
var composer = new CardComposer(store, new FakeSettingsStore());
return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore);
}
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
private static AiParsedCardDto Parsed(
string title = "",
AiBudgetDto? budget = null,
IReadOnlyList<AiContactDto>? contacts = null,
bool isVacancy = false,
bool isVacancyKnown = false,
string? board = null) => new(
title, Company: null, Format: null, Task: null, Requirements: null, Plus: null, Conditions: null,
Summary: null,
Stack: Array.Empty<string>(),
budget,
contacts ?? Array.Empty<AiContactDto>(),
isVacancy, isVacancyKnown, IsSpam: false, board);
// Строка очереди-источника (поля сообщения; статус/время постановки не важны).
private static QueueItemDto Message(
string text,
string dialogId = "d_1",
long? msgId = 123) => SourceItemFactory.Queue(
"p_1",
text,
PipelineQueueStatuses.Filtered,
origin: dialogId,
externalId: msgId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
@@ -0,0 +1,223 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Grpc.Ai;
using Deal.Infrastructure.Data;
using Deal.Infrastructure.Integrations.Models;
using Deal.Infrastructure.Integrations.Options;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
using Microsoft.Extensions.Logging.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Сквозной тест воркера pump с gRPC-классификатором ai-service
/// </summary>
[Collection("MlGrpcTests")]
public sealed class PipelineWorkerGrpcAiTests
{
// Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service.
private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab";
// Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage).
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
[Fact]
public async Task Pump_GrpcAiClassifier_FilterAndClassifyAgainstInProcAiService()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FilterReply = new FilterReply
{
Pass = true,
Usage = new Usage { Prompt = 120, Completion = 10, Total = 130 },
};
service.ClassifyReply = new ClassifyReply
{
Ok = true,
Json = """
{"title":"Middle Python в команду","is_vacancy":true,"is_spam":false,
"stack":["Python"],"budget":{"from":2000,"to":2500,"currency":"USD"},
"contacts":["@ivan_dev"]}
""",
Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 },
};
Context ctx = CreateContext(port);
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$"));
PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None);
// Классификация реальным gRPC-адаптером: карточка создана ИИ-путём (не aiFail/локальная заглушка).
Assert.Equal(1, result.AiStored);
Assert.Equal(0, result.AiFail);
Assert.Single(result.CreatedCards);
Assert.Empty(ctx.PipelineStore.Queue);
Assert.Empty(ctx.PipelineStore.Rejected);
CardDto card = Assert.Single(result.CreatedCards);
Assert.Equal("Middle Python в команду", card.Title);
Assert.Equal(KanbanColumns.Inbox, card.Col); // board не назначен моделью → «Неразобранное»
Assert.True(card.IsVacancy);
Assert.True(card.IsVacancyKnown); // стемп успешной классификации воркера (python L11081111)
Assert.Equal(new CardBudgetDto(2000, 2500, "USD"), card.Budget);
Assert.Equal(1, service.FilterCalls);
Assert.Equal(1, service.ClassifyCalls);
Assert.All(service.RequestTenantIds, id => Assert.Equal(TenantIdValue, id));
Assert.All(service.RequestTokens, token => Assert.Equal(AiGrpcTestHost.DefaultToken, token));
Assert.Equal("{\"prompt\":2120,\"completion\":410,\"total\":2530}", ctx.Settings.GetStoredJson(SettingsKeys.AiTokenUsage));
Assert.Equal(2530, ctx.Limits.UsedTokens(TenantGuid));
});
}
[Fact]
public async Task Pump_GrpcAiServiceUnavailable_FallsBackToLocalParse()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FilterUnavailable = true;
service.ClassifyUnavailable = true;
Context ctx = CreateContext(port);
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$"));
PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None);
Assert.Equal(1, result.AiFail);
Assert.Equal(1, result.AiStored);
Assert.Single(result.CreatedCards);
Assert.Equal(1, service.FilterCalls);
Assert.Equal(1, service.ClassifyCalls);
});
}
[Fact]
public async Task Pump_BudgetExhausted_GateUsesLocalClassifierWithoutPaidRpc()
{
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
// Реальный ai-service ответил бы — но гейт (бюджет исчерпан) не должен пустить к нему ни одного RPC.
service.FilterReply = new FilterReply
{
Pass = true,
Usage = new Usage { Prompt = 120, Completion = 10, Total = 130 },
};
service.ClassifyReply = new ClassifyReply
{
Ok = true,
Json = """
{"title":"Платная ветка","is_vacancy":true,"is_spam":false}
""",
Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 },
};
var limits = new FakeTenantLimitStore();
limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000);
Context ctx = CreateContext(port, limits, budgeted: true);
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц"));
PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(CancellationToken.None);
// Платные RPC не вызывались: гейт запретил (исчерпан), приём/обработка не заблокированы.
Assert.Equal(0, service.FilterCalls);
Assert.Equal(0, service.ClassifyCalls);
Assert.Empty(service.RequestTenantIds);
Assert.Equal(1, result.AiStored);
Assert.Equal(0, result.AiFail);
Assert.Single(result.CreatedCards);
Assert.Equal(1000, limits.UsedTokens(TenantGuid)); // списаний не было (платных вызовов нет)
CardDto card = Assert.Single(ctx.KanjStore.CardDtos);
Assert.Equal(KanbanColumns.Inbox, card.Col); // локальный путь не назначает смысловые колонки
Assert.Empty(ctx.PipelineStore.Queue);
Assert.Empty(ctx.PipelineStore.Rejected);
});
}
// ─── Контекст воркера ───────────────────────────────────────────────────
private sealed record Context(
PipelineWorkerService Worker,
FakePipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
FakeTenantLimitStore Limits);
private static Context CreateContext(
int port,
FakeTenantLimitStore? limits = null,
bool budgeted = false)
{
var settings = new FakeSettingsStore();
var pipelineStore = new FakePipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var connection = new AiGrpcConnection(
new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
limits ??= new FakeTenantLimitStore();
var grpcClassifier = new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, new FakeSecretCipher()),
new AiClassifyContextBuilder(settings, kanjStore),
new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())),
NullLogger<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted
? new BudgetedAiClassifier(
grpcClassifier,
new LocalAiClassifier(fieldsParser),
limits,
tenantContext,
NullLogger<BudgetedAiClassifier>.Instance)
: grpcClassifier;
var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore));
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer);
var worker = new PipelineWorkerService(
pipelineStore, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser);
return new Context(worker, pipelineStore, kanjStore, settings, limits);
}
// Строка очереди статуса filtered (уже прошла «new»-проход и ждёт ИИ).
// id: Id строки (p_…).
// text: Текст сообщения.
// Возвращает: Строка как если бы была переведена в filtered воркером.
private static QueueItemDto QueueRowFiltered(string id, string text) => SourceItemFactory.Queue(
id,
text,
PipelineQueueStatuses.Filtered,
msgAtMs: DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
// Неготовая модель: «не уверена» (ML-слот воркера не решает — сообщение уходит к ИИ).
// Возвращает: Фиксированный «не готов/не уверен».
private static MlPredictResultDto NotReadyPrediction() => new(
Take: false,
Label: null,
Scores: new Dictionary<string, double>(),
Hits: 0,
Ready: false,
Margin: null,
Terms: Array.Empty<string>(),
Type: null);
}
@@ -0,0 +1,55 @@
using Deal.Contracts.Integrations.Abstractions;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Порт-контракт гейта telegram-service
/// </summary>
public sealed class TelegramGatewayPortTests
{
private static readonly string[] ExpectedMethods =
[
"StatusAsync",
"StartPhoneAsync",
"StartQrAsync",
"SendCodeAsync",
"SendPasswordAsync",
"LogoutAsync",
"RefreshDialogsAsync",
"SetMonitorAsync",
"SetMonitorAllAsync",
"BackfillAsync",
"ReadRecentAsync",
"ReadSourceAsync",
"SearchAsync",
"InfoAsync",
"ReadForEvalAsync",
"JoinAsync",
"LeaveAsync",
];
[Fact]
public void TelegramGateway_ExposesExactlyTheRuling7CommandSet()
{
string[] actual = typeof(ITelegramGateway)
.GetMethods()
.Select(method => method.Name)
.Distinct()
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
string[] expected = ExpectedMethods.OrderBy(name => name, StringComparer.Ordinal).ToArray();
Assert.Equal(expected, actual);
}
[Fact]
public void TelegramGateway_AllMethodsAreAsyncTaskBased()
{
foreach (System.Reflection.MethodInfo method in typeof(ITelegramGateway).GetMethods())
{
Assert.True(
typeof(Task).IsAssignableFrom(method.ReturnType),
$"Метод {method.Name} обязан возвращать Task/Task<T> (async-порт).");
}
}
}
@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="6.1.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<!-- Клиент gRPC в тестах ингресса (транспорт HTTP/2 к тестовому хосту Kestrel). -->
<PackageReference Include="Grpc.Net.Client" Version="2.83.0" />
<!-- EF InMemory-провайдер для юнит-маппинга EF-адаптеров без Postgres (OperatorAuthStore; Ruling 1). -->
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.11" />
</ItemGroup>
<ItemGroup>
<!-- WebApplication/Kestrel-типы хостов тестов (эталон Deal.Telegram.Tests). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Deal.SharedKernel\Deal.SharedKernel.csproj" />
<ProjectReference Include="..\..\Deal.Contracts\Deal.Contracts.csproj" />
<ProjectReference Include="..\..\Deal.Modules.Pipeline\Deal.Modules.Pipeline.csproj" />
<ProjectReference Include="..\..\Deal.Modules.Kanban\Deal.Modules.Kanban.csproj" />
<ProjectReference Include="..\..\Deal.Modules.Cards\Deal.Modules.Cards.csproj" />
<ProjectReference Include="..\..\Deal.Modules.Discovery\Deal.Modules.Discovery.csproj" />
<ProjectReference Include="..\..\Deal.Modules.Settings\Deal.Modules.Settings.csproj" />
<ProjectReference Include="..\..\Deal.Modules.Telegram\Deal.Modules.Telegram.csproj" />
<ProjectReference Include="..\..\Deal.Modules.Tenants\Deal.Modules.Tenants.csproj" />
<ProjectReference Include="..\..\Deal.Infrastructure\Deal.Infrastructure.csproj" />
<ProjectReference Include="..\..\Deal.Api\Deal.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,165 @@
using Deal.Grpc.Ai;
using Grpc.Core;
namespace Deal.Tests.Unit.Grpc;
/// <summary>
/// In-proc фейк ai-service для тестов gRPC-адаптеров ядра
/// </summary>
public sealed class RecordingAiService : AiService.AiServiceBase
{
/// <summary>
/// Ответ Filter по умолчанию
/// </summary>
public FilterReply FilterReply { get; set; } = new() { Pass = true };
/// <summary>
/// Ответ Classify по умолчанию
/// </summary>
public ClassifyReply ClassifyReply { get; set; } = new();
/// <summary>
/// Ответ GenerateKeywords по умолчанию
/// </summary>
public GenerateKeywordsReply GenerateKeywordsReply { get; set; } = new();
/// <summary>
/// Ответ EvaluateFit по умолчанию
/// </summary>
public EvaluateFitReply EvaluateFitReply { get; set; } = new() { Fit = true };
/// <summary>
/// Сбоить ли Filter статусом UNAVAILABLE
/// </summary>
public bool FilterUnavailable { get; set; }
/// <summary>
/// Сбоить ли Classify статусом UNAVAILABLE.
/// </summary>
public bool ClassifyUnavailable { get; set; }
/// <summary>
/// Сбоить ли GenerateKeywords статусом UNAVAILABLE.
/// </summary>
public bool KeywordsUnavailable { get; set; }
/// <summary>
/// Сбоить ли EvaluateFit статусом UNAVAILABLE.
/// </summary>
public bool FitUnavailable { get; set; }
/// <summary>
/// Число вызовов Filter.
/// </summary>
public int FilterCalls { get; private set; }
/// <summary>
/// Число вызовов Classify.
/// </summary>
public int ClassifyCalls { get; private set; }
/// <summary>
/// Число вызовов GenerateKeywords.
/// </summary>
public int KeywordsCalls { get; private set; }
/// <summary>
/// Число вызовов EvaluateFit.
/// </summary>
public int FitCalls { get; private set; }
/// <summary>
/// Последний запрос Filter
/// </summary>
public FilterRequest? LastFilter { get; private set; }
/// <summary>
/// Последний запрос Classify
/// </summary>
public ClassifyRequest? LastClassify { get; private set; }
/// <summary>
/// Последний запрос GenerateKeywords.
/// </summary>
public GenerateKeywordsRequest? LastKeywords { get; private set; }
/// <summary>
/// Последний запрос EvaluateFit.
/// </summary>
public EvaluateFitRequest? LastFit { get; private set; }
/// <summary>
/// tenant-id из metadata вызовов
/// </summary>
public List<string> RequestTenantIds { get; } = [];
/// <summary>
/// service-token из metadata вызовов
/// </summary>
public List<string> RequestTokens { get; } = [];
/// <inheritdoc />
public override Task<FilterReply> Filter(FilterRequest request, ServerCallContext context)
{
FilterCalls++;
RecordMetadata(context);
if (FilterUnavailable)
{
throw Unavailable();
}
LastFilter = request;
return Task.FromResult(FilterReply);
}
/// <inheritdoc />
public override Task<ClassifyReply> Classify(ClassifyRequest request, ServerCallContext context)
{
ClassifyCalls++;
RecordMetadata(context);
if (ClassifyUnavailable)
{
throw Unavailable();
}
LastClassify = request;
return Task.FromResult(ClassifyReply);
}
/// <inheritdoc />
public override Task<GenerateKeywordsReply> GenerateKeywords(GenerateKeywordsRequest request, ServerCallContext context)
{
KeywordsCalls++;
RecordMetadata(context);
if (KeywordsUnavailable)
{
throw Unavailable();
}
LastKeywords = request;
return Task.FromResult(GenerateKeywordsReply);
}
/// <inheritdoc />
public override Task<EvaluateFitReply> EvaluateFit(EvaluateFitRequest request, ServerCallContext context)
{
FitCalls++;
RecordMetadata(context);
if (FitUnavailable)
{
throw Unavailable();
}
LastFit = request;
return Task.FromResult(EvaluateFitReply);
}
private void RecordMetadata(ServerCallContext context)
{
RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty);
RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty);
}
private static RpcException Unavailable()
=> new(new Status(StatusCode.Unavailable, "ИИ (DeepSeek) не ответил корректно — повторите попытку через несколько секунд"));
}
@@ -0,0 +1,142 @@
using Deal.Grpc.Ml;
using Grpc.Core;
namespace Deal.Tests.Unit.Grpc;
/// <summary>
/// In-proc фейк ml-service для тестов gRPC-клиента ядра
/// </summary>
public sealed class RecordingMlService : MlService.MlServiceBase
{
/// <summary>
/// Ответ Predict по умолчанию
/// </summary>
public PredictReply PredictReply { get; set; } = new();
/// <summary>
/// Ответ Status по умолчанию
/// </summary>
public StatusReply StatusReply { get; set; } = new();
/// <summary>
/// Ответ Reset по умолчанию
/// </summary>
public ResetReply ResetReply { get; set; } = new() { Ok = true };
/// <summary>
/// Сбоить ли Predict статусом UNAVAILABLE
/// </summary>
public bool PredictUnavailable { get; set; }
/// <summary>
/// Сбоить ли Status статусом UNAVAILABLE.
/// </summary>
public bool StatusUnavailable { get; set; }
/// <summary>
/// Сбоить ли Reset статусом UNAVAILABLE.
/// </summary>
public bool ResetUnavailable { get; set; }
/// <summary>
/// Сбоить ли TrainBatch статусом UNAVAILABLE.
/// </summary>
public bool TrainUnavailable { get; set; }
/// <summary>
/// Число вызовов Predict.
/// </summary>
public int PredictCalls { get; private set; }
/// <summary>
/// Число вызовов Status.
/// </summary>
public int StatusCalls { get; private set; }
/// <summary>
/// Число вызовов Reset.
/// </summary>
public int ResetCalls { get; private set; }
/// <summary>
/// Число вызовов TrainBatch.
/// </summary>
public int TrainCalls { get; private set; }
/// <summary>
/// Полученные батчи обучения
/// </summary>
public List<TrainBatchRequest> TrainBatches { get; } = [];
/// <summary>
/// tenant-id из metadata вызовов
/// </summary>
public List<string> RequestTenantIds { get; } = [];
/// <summary>
/// service-token из metadata вызовов
/// </summary>
public List<string> RequestTokens { get; } = [];
/// <inheritdoc />
public override Task<PredictReply> Predict(PredictRequest request, ServerCallContext context)
{
PredictCalls++;
RecordMetadata(context);
if (PredictUnavailable)
{
throw Unavailable();
}
return Task.FromResult(PredictReply);
}
/// <inheritdoc />
public override Task<StatusReply> Status(StatusRequest request, ServerCallContext context)
{
StatusCalls++;
RecordMetadata(context);
if (StatusUnavailable)
{
throw Unavailable();
}
return Task.FromResult(StatusReply);
}
/// <inheritdoc />
public override Task<ResetReply> Reset(ResetRequest request, ServerCallContext context)
{
ResetCalls++;
RecordMetadata(context);
if (ResetUnavailable)
{
throw Unavailable();
}
return Task.FromResult(ResetReply);
}
/// <inheritdoc />
public override Task<TrainBatchReply> TrainBatch(TrainBatchRequest request, ServerCallContext context)
{
TrainCalls++;
RecordMetadata(context);
if (TrainUnavailable)
{
throw Unavailable();
}
TrainBatches.Add(request);
return Task.FromResult(new TrainBatchReply { Learned = request.Items.Count });
}
private void RecordMetadata(ServerCallContext context)
{
RequestTenantIds.Add(context.RequestHeaders.GetValue("tenant-id") ?? string.Empty);
RequestTokens.Add(context.RequestHeaders.GetValue("service-token") ?? string.Empty);
}
private static RpcException Unavailable()
=> new(new Status(StatusCode.Unavailable, "ml-service недоступен (тест)"));
}
@@ -0,0 +1,197 @@
using Deal.Grpc.Telegram;
using Grpc.Core;
namespace Deal.Tests.Unit.Grpc;
/// <summary>
/// Фейк-сервер TelegramService
/// </summary>
public sealed class RecordingTelegramService : TelegramService.TelegramServiceBase
{
/// <summary>
/// Последний запрос StartPhone
/// </summary>
public StartPhoneRequest? LastStartPhone { get; private set; }
/// <summary>
/// Последний запрос StartQr
/// </summary>
public StartQrRequest? LastStartQr { get; private set; }
/// <summary>
/// Запросы Backfill
/// </summary>
public List<BackfillRequest> BackfillRequests { get; } = [];
/// <summary>
/// Запросы ReadRecent
/// </summary>
public List<ReadRecentRequest> ReadRecentRequests { get; } = [];
/// <summary>
/// Запросы ReadSource
/// </summary>
public List<ReadSourceRequest> ReadSourceRequests { get; } = [];
/// <summary>
/// found ответа ReadSource
/// </summary>
public bool ReadSourceFound { get; set; }
/// <summary>
/// text ответа ReadSource (null — поле не задано)
/// </summary>
public string? ReadSourceText { get; set; }
/// <summary>
/// time ответа ReadSource (null — поле не задано)
/// </summary>
public long? ReadSourceTime { get; set; }
/// <summary>
/// Каталог ответа RefreshDialogs
/// </summary>
public List<DialogEntry> Catalog { get; } = [];
/// <summary>
/// Сколько сообщений «разобрал» Backfill
/// </summary>
public int BackfillProcessed { get; set; } = 5;
/// <summary>
/// Доменный сбой следующего вызова
/// </summary>
public RpcException? DomainFailure { get; set; }
/// <summary>
/// Фаза/поля ответа GetStatus.
/// </summary>
public string StatusPhase { get; set; } = "idle";
/// <summary>
/// Аккаунт ответа GetStatus
/// </summary>
public string StatusAccount { get; set; } = string.Empty;
/// <summary>
/// Connected ответа GetStatus.
/// </summary>
public bool StatusConnected { get; set; }
/// <summary>
/// Listener ответа GetStatus.
/// </summary>
public bool StatusListener { get; set; }
/// <summary>
/// qrUrl ответа GetStatus/StartQr
/// </summary>
public string QrUrl { get; set; } = string.Empty;
/// <summary>
/// error ответа GetStatus
/// </summary>
public string StatusError { get; set; } = string.Empty;
/// <inheritdoc />
public override Task<GetStatusReply> GetStatus(GetStatusRequest request, ServerCallContext context)
{
ThrowIfDomainFailure(context);
var reply = new GetStatusReply
{
Phase = StatusPhase,
Connected = StatusConnected,
Listener = StatusListener,
Account = StatusAccount,
};
if (StatusError.Length > 0)
{
reply.Error = StatusError;
}
if (QrUrl.Length > 0)
{
reply.QrUrl = QrUrl;
}
return Task.FromResult(reply);
}
/// <inheritdoc />
public override Task<StartPhoneReply> StartPhone(StartPhoneRequest request, ServerCallContext context)
{
ThrowIfDomainFailure(context);
LastStartPhone = request;
return Task.FromResult(new StartPhoneReply { Phase = "code" });
}
/// <inheritdoc />
public override Task<StartQrReply> StartQr(StartQrRequest request, ServerCallContext context)
{
ThrowIfDomainFailure(context);
LastStartQr = request;
var reply = new StartQrReply { Phase = "qr" };
if (QrUrl.Length > 0)
{
reply.QrUrl = QrUrl;
}
return Task.FromResult(reply);
}
/// <inheritdoc />
public override Task<RefreshDialogsReply> RefreshDialogs(RefreshDialogsRequest request, ServerCallContext context)
{
ThrowIfDomainFailure(context);
var reply = new RefreshDialogsReply();
reply.Entries.AddRange(Catalog);
return Task.FromResult(reply);
}
/// <inheritdoc />
public override Task<BackfillReply> Backfill(BackfillRequest request, ServerCallContext context)
{
ThrowIfDomainFailure(context);
BackfillRequests.Add(request);
return Task.FromResult(new BackfillReply { Processed = BackfillProcessed });
}
/// <inheritdoc />
public override Task<ReadRecentReply> ReadRecent(ReadRecentRequest request, ServerCallContext context)
{
ThrowIfDomainFailure(context);
ReadRecentRequests.Add(request);
var reply = new ReadRecentReply();
reply.Messages.Add(new PreviewMessage { Id = "100500", Text = "Свежее", Time = 1700000000000 });
return Task.FromResult(reply);
}
/// <inheritdoc />
public override Task<ReadSourceReply> ReadSource(ReadSourceRequest request, ServerCallContext context)
{
ThrowIfDomainFailure(context);
ReadSourceRequests.Add(request);
var reply = new ReadSourceReply { Found = ReadSourceFound };
if (ReadSourceText is not null)
{
reply.Text = ReadSourceText;
}
if (ReadSourceTime is not null)
{
reply.Time = ReadSourceTime.Value;
}
return Task.FromResult(reply);
}
// Бросает доменный сбой сценария (если задан) — как RPC-ошибка telegram-service.
// context: Контекст вызова (не используется, но держит сигнатуру единообразной).
private void ThrowIfDomainFailure(ServerCallContext context)
{
if (DomainFailure is not null)
{
throw DomainFailure;
}
}
}
@@ -0,0 +1,143 @@
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="AuditLogStore"/> на InMemory-провайдере
/// </summary>
public sealed class AuditLogStoreTests
{
[Fact]
public async Task AppendAsync_ThenQueryAll_ReturnsMappedRecord()
{
var store = CreateStore();
var actorId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var at = DateTimeOffset.UtcNow;
await store.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
ActorId: actorId,
TenantId: tenantId,
Ip: "127.0.0.1",
DetailJson: """{"login":"admin"}""",
At: at), CancellationToken.None);
var records = await store.QueryAsync(EmptyFilter(100), CancellationToken.None);
var record = Assert.Single(records);
Assert.Equal(AuditEvents.TenantLoginOk, record.EventType);
Assert.Equal(AuditActorTypes.Tenant, record.ActorType);
Assert.Equal(actorId, record.ActorId);
Assert.Equal(tenantId, record.TenantId);
Assert.Equal("127.0.0.1", record.Ip);
Assert.Equal("""{"login":"admin"}""", record.DetailJson);
Assert.Equal(at, record.At);
}
[Fact]
public async Task QueryAsync_OrdersByAtDescending_AndAppliesFilters()
{
var store = CreateStore();
var tenantId = Guid.NewGuid();
var older = DateTimeOffset.UtcNow.AddMinutes(-10);
var middle = DateTimeOffset.UtcNow.AddMinutes(-5);
var newer = DateTimeOffset.UtcNow;
await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, at: older), CancellationToken.None);
await store.AppendAsync(Record(AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, tenantId, middle), CancellationToken.None);
await store.AppendAsync(Record(AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, newer), CancellationToken.None);
var all = await store.QueryAsync(EmptyFilter(100), CancellationToken.None);
Assert.Equal(3, all.Count);
Assert.Equal(AuditEvents.TenantLoginOk, all[0].EventType);
Assert.Equal(AuditEvents.TenantLoginFailed, all[1].EventType);
Assert.Equal(AuditEvents.OperatorLoginOk, all[2].EventType);
var byTenant = await store.QueryAsync(
new AuditQueryDto(null, null, tenantId, null, null, 100), CancellationToken.None);
Assert.Equal(2, byTenant.Count);
var byEvent = await store.QueryAsync(
new AuditQueryDto(AuditEvents.TenantLoginFailed, null, null, null, null, 100), CancellationToken.None);
Assert.Equal(AuditEvents.TenantLoginFailed, Assert.Single(byEvent).EventType);
var byRange = await store.QueryAsync(
new AuditQueryDto(null, null, null, middle, newer, 100), CancellationToken.None);
Assert.Equal(2, byRange.Count);
var byActor = await store.QueryAsync(
new AuditQueryDto(null, AuditActorTypes.Tenant, null, null, null, 100), CancellationToken.None);
Assert.Equal(2, byActor.Count);
}
[Fact]
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
{
var store = CreateStore();
for (int i = 0; i < 5; i++)
{
await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow), CancellationToken.None);
}
var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 1);
Assert.Equal(5, await store.CountAsync(filter, CancellationToken.None));
Assert.Single(await store.QueryAsync(filter, CancellationToken.None));
}
[Fact]
public async Task QueryAsync_ClampsLimitToFiveHundred()
{
var store = CreateStore();
for (int i = 0; i < AuditService.MaxQueryLimit + 5; i++)
{
await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow), CancellationToken.None);
}
var records = await store.QueryAsync(EmptyFilter(int.MaxValue), CancellationToken.None);
Assert.Equal(AuditService.MaxQueryLimit, records.Count);
}
/// <summary>
/// Авто-очистка: удаляет только записи старше границы, свежие остаются.
/// </summary>
[Fact]
public async Task PurgeOlderThanAsync_RemovesOnlyRecordsOlderThanCutoff()
{
var store = CreateStore();
var old = DateTimeOffset.UtcNow.AddDays(-200);
var recent = DateTimeOffset.UtcNow.AddDays(-10);
await store.AppendAsync(Record(AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, old), CancellationToken.None);
await store.AppendAsync(Record(AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: Guid.NewGuid(), recent), CancellationToken.None);
int removed = await store.PurgeOlderThanAsync(DateTimeOffset.UtcNow.AddDays(-180), CancellationToken.None);
Assert.Equal(1, removed);
var remaining = Assert.Single(await store.QueryAsync(EmptyFilter(100), CancellationToken.None));
Assert.Equal(AuditEvents.TenantLoginOk, remaining.EventType);
}
// Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются).
private static AuditLogStore CreateStore()
{
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
return new AuditLogStore(new DealDbContext(options));
}
// Запись аудита с деталями по умолчанию.
private static AuditRecordDto Record(
string eventType,
string actorType,
Guid? tenantId,
DateTimeOffset at) =>
new(eventType, actorType, ActorId: null, tenantId, Ip: "127.0.0.1", DetailJson: null, at);
private static AuditQueryDto EmptyFilter(int limit) =>
new(EventType: null, ActorType: null, TenantId: null, From: null, To: null, Limit: limit);
}
@@ -0,0 +1,80 @@
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="AuthStore"/> на InMemory-провайдере
/// </summary>
public sealed class AuthStoreTests
{
[Fact]
public async Task ListUsersByTenantIdAsync_ReturnsOnlyTenantUsers()
{
var store = CreateStore();
var tenantId = Guid.NewGuid();
var otherTenantId = Guid.NewGuid();
await CreateUserAsync(store, tenantId, "first@example.com");
await CreateUserAsync(store, tenantId, "second@example.com");
await CreateUserAsync(store, otherTenantId, "other@example.com");
IReadOnlyList<UserIdentityDto> users = await store.ListUsersByTenantIdAsync(tenantId, CancellationToken.None);
Assert.Equal(2, users.Count);
// Порядок EF: CreatedAt, затем Login — «первый пользователь» для impersonation без login детерминирован.
Assert.Equal("first@example.com", users[0].Login);
Assert.Equal("second@example.com", users[1].Login);
Assert.All(users, u => Assert.Equal(tenantId, u.TenantId));
Assert.DoesNotContain(users, u => u.Login == "other@example.com");
}
[Fact]
public async Task CreateSession_ThenFindByTokenHash_ReturnsImpersonationMarker()
{
var store = CreateStore();
var userId = Guid.NewGuid();
await CreateUserAsync(store, Guid.NewGuid(), "user@example.com");
var operatorId = Guid.NewGuid();
string tokenHash = "token-hash-impersonation";
await store.CreateSessionAsync(
new SessionDto(tokenHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30), operatorId),
CancellationToken.None);
SessionDto? found = await store.FindSessionByTokenHashAsync(tokenHash, CancellationToken.None);
Assert.NotNull(found);
Assert.Equal(operatorId, found!.ImpersonatedByOperatorId);
Assert.Equal(userId, found.UserId);
// Обычная сессия (без маркера) читается как ImpersonatedByOperatorId=null.
string plainHash = "token-hash-plain";
await store.CreateSessionAsync(
new SessionDto(plainHash, userId, "user@example.com", DateTimeOffset.UtcNow.AddDays(30)),
CancellationToken.None);
SessionDto? plain = await store.FindSessionByTokenHashAsync(plainHash, CancellationToken.None);
Assert.NotNull(plain);
Assert.Null(plain!.ImpersonatedByOperatorId);
}
// Создаёт пользователя тенанта (хэш пароля — заглушка, для чтения не нужен).
private static async Task CreateUserAsync(
AuthStore store,
Guid tenantId,
string login)
{
await store.CreateUserAsync(
new StoredUserDto(Guid.NewGuid(), login, tenantId, "active", "fake-argon2-encoded-hash"),
CancellationToken.None);
}
// Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются).
private static AuthStore CreateStore()
{
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
return new AuthStore(new DealDbContext(options));
}
}
@@ -0,0 +1,87 @@
using Deal.Infrastructure.Services;
using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Тесты единой точки перехода карточки <see cref="CardMover"/>
/// </summary>
public sealed class CardMoverTests
{
private static readonly TransitionContext UserMove = new() { Actor = "user", Learn = true };
[Fact]
public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder()
{
(CardMover mover, FakeKanjStore store) = Create();
store.SeedCard(new CardDto
{
Id = "c_1",
Col = "inbox",
Title = "Заказ",
Reminder = new CardReminderDto(1000),
});
CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None);
Assert.Null(result.Error);
Assert.True(result.Exists);
CardDto card = Assert.Single(store.CardDtos);
Assert.Equal(CardsDefaultContainers.Planned, card.Col);
Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание
CardHistoryDto entry = Assert.Single(card.History);
Assert.Equal(CardsDefaultContainers.Planned, entry.Stage); // запись истории о переносе
}
[Fact]
public async Task Move_ToBoard_RoutesToDashboard()
{
(CardMover mover, FakeKanjStore store) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" });
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен Python" } });
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None);
Assert.Null(result.Error);
Assert.True(result.Exists);
Assert.Equal("b_py", Assert.Single(store.CardDtos).Col);
Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса
}
[Fact]
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError()
{
(CardMover mover, FakeKanjStore store) = Create();
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Текст" } });
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error);
Assert.True(result.Exists); // ошибка важнее признака наличия
}
[Fact]
public async Task Move_ToStage_CardMissing_ReportsNotFound()
{
(CardMover mover, _) = Create();
CardMoveResultDto result = await mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None);
Assert.Null(result.Error);
Assert.False(result.Exists);
}
private static (CardMover Mover, FakeKanjStore Store) Create()
{
var store = new FakeKanjStore();
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new FakeFileStorage());
return (new CardMover(cardsService), store);
}
}
@@ -0,0 +1,56 @@
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Settings.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="GlobalSettingsStore"/> на InMemory-провайдере
/// </summary>
public sealed class GlobalSettingsStoreTests
{
[Fact]
public async Task GetAsync_MissingKey_ReturnsNull()
{
GlobalSettingsStore store = CreateStore();
SettingValue? value = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None);
Assert.Null(value);
}
[Fact]
public async Task SetAsync_NewKey_CreatesRowWithUtcTimestamp()
{
GlobalSettingsStore store = CreateStore();
await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None);
SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!;
Assert.Equal("{\"apiId\":\"1234567\"}", value.ValueJson);
Assert.True(value.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes(-1));
}
[Fact]
public async Task SetAsync_ExistingKey_OverwritesValue()
{
GlobalSettingsStore store = CreateStore();
await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None);
await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"7654321\"}", CancellationToken.None);
SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!;
Assert.Equal("{\"apiId\":\"7654321\"}", value.ValueJson);
}
// Создаёт адаптер на свежем InMemory-контексте.
// Возвращает: Адаптер глобального KV-хранилища.
private static GlobalSettingsStore CreateStore()
{
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
return new GlobalSettingsStore(new DealDbContext(options));
}
}
@@ -0,0 +1,120 @@
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="InviteStore"/> на InMemory-провайдере
/// </summary>
public sealed class InviteStoreTests
{
[Fact]
public async Task Create_ThenGetByCode_ReturnsMappedInvite()
{
var store = CreateStore();
var invite = NewInvite("code-12345678", "user@example.com", Guid.NewGuid());
await store.CreateAsync(invite, CancellationToken.None);
var found = await store.GetByCodeAsync("code-12345678", CancellationToken.None);
Assert.NotNull(found);
Assert.Equal(invite.Code, found!.Code);
Assert.Equal(invite.Email, found.Email);
Assert.Equal(invite.TenantId, found.TenantId);
Assert.Equal(invite.Status, found.Status);
Assert.Equal(invite.ExpiresAt, found.ExpiresAt);
Assert.Equal(invite.ActivatedAt, found.ActivatedAt);
Assert.Equal(invite.CreatedById, found.CreatedById);
Assert.Equal(invite.CreatedAt, found.CreatedAt);
}
[Fact]
public async Task List_ReturnsInvitesNewestFirst()
{
var store = CreateStore();
var older = NewInvite("older-code", "older@example.com", tenantId: null, createdAt: DateTimeOffset.UtcNow.AddDays(-2));
var newer = NewInvite("newer-code", "newer@example.com", tenantId: null, createdAt: DateTimeOffset.UtcNow.AddDays(-1));
await store.CreateAsync(older, CancellationToken.None);
await store.CreateAsync(newer, CancellationToken.None);
IReadOnlyList<InviteDto> items = await store.ListAsync(CancellationToken.None);
Assert.Equal(new[] { "newer-code", "older-code" }, items.Select(i => i.Code));
}
[Fact]
public async Task UpdateStatus_ChangesStatusAndActivatedAt()
{
var store = CreateStore();
var activatedAt = DateTimeOffset.UtcNow.AddHours(-1);
await store.CreateAsync(NewInvite("code-1", "user@example.com", Guid.NewGuid()), CancellationToken.None);
bool updated = await store.UpdateStatusAsync("code-1", InviteStatuses.Activated, activatedAt, CancellationToken.None);
Assert.True(updated);
var found = await store.GetByCodeAsync("code-1", CancellationToken.None);
Assert.Equal(InviteStatuses.Activated, found!.Status);
Assert.Equal(activatedAt, found.ActivatedAt);
}
[Fact]
public async Task UpdateStatus_WithUnknownCode_ReturnsFalse()
{
var store = CreateStore();
bool updated = await store.UpdateStatusAsync("no-such-code", InviteStatuses.Revoked, null, CancellationToken.None);
Assert.False(updated);
}
[Fact]
public async Task FindActiveByEmail_ReturnsOnlyPendingInvite()
{
var store = CreateStore();
await store.CreateAsync(NewInvite("pending-code", "user@example.com", tenantId: null), CancellationToken.None);
await store.CreateAsync(NewInvite("revoked-code", "revoked@example.com", tenantId: null, status: InviteStatuses.Revoked), CancellationToken.None);
await store.CreateAsync(NewInvite("activated-code", "activated@example.com", tenantId: null, status: InviteStatuses.Activated), CancellationToken.None);
Assert.Equal("pending-code", (await store.FindActiveByEmailAsync("user@example.com", CancellationToken.None))!.Code);
Assert.Null(await store.FindActiveByEmailAsync("revoked@example.com", CancellationToken.None));
Assert.Null(await store.FindActiveByEmailAsync("activated@example.com", CancellationToken.None));
Assert.Null(await store.FindActiveByEmailAsync("unknown@example.com", CancellationToken.None));
}
[Fact]
public async Task GetByCode_WithUnknownCode_ReturnsNull()
{
var store = CreateStore();
Assert.Null(await store.GetByCodeAsync("no-such-code", CancellationToken.None));
}
// Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются).
private static InviteStore CreateStore()
{
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
return new InviteStore(new DealDbContext(options));
}
// Создаёт приглашение-строку для теста (дефолт — живой pending).
private static InviteDto NewInvite(
string code,
string email,
Guid? tenantId,
string status = InviteStatuses.Pending,
DateTimeOffset? createdAt = null) =>
new(
code,
email,
tenantId,
status,
DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours),
ActivatedAt: null,
CreatedById: Guid.NewGuid(),
createdAt ?? DateTimeOffset.UtcNow);
}
@@ -0,0 +1,103 @@
using Deal.Infrastructure.Integrations.Options;
using Microsoft.Extensions.Configuration;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Тесты конфиг-парсинга mTLS
/// </summary>
public sealed class MtlsOptionsTests
{
/// <summary>
/// Пустой конфиг — флаг выключен и все пути пустые
/// </summary>
[Fact]
public void FromConfiguration_WithoutEnvKeys_ReturnsDisabledDefaults()
{
MtlsOptions options = MtlsOptions.FromConfiguration(EmptyConfiguration());
Assert.False(options.Enabled);
Assert.Equal(string.Empty, options.CaPem);
Assert.Equal(string.Empty, options.ServerCertPfx);
Assert.Equal(string.Empty, options.ServerCertPassword);
Assert.Equal(string.Empty, options.ClientCertPfx);
Assert.Equal(string.Empty, options.ClientCertPassword);
}
/// <summary>
/// «1»/«true» в любом регистре включают флаг mTLS.
/// </summary>
[Theory]
[InlineData("1")]
[InlineData("true")]
[InlineData("TRUE")]
[InlineData("True")]
public void FromConfiguration_EnablingEnvValues_EnabledTrue(string rawValue)
{
MtlsOptions options = MtlsOptions.FromConfiguration(Configuration(
(MtlsOptions.EnabledEnvKey, rawValue)));
Assert.True(options.Enabled);
}
/// <summary>
/// «0»/«false»/прочие значения/отсутствие флага — mTLS выключен
/// </summary>
[Theory]
[InlineData("0")]
[InlineData("false")]
[InlineData("no")]
[InlineData("yes")]
[InlineData("2")]
public void FromConfiguration_DisablingEnvValues_EnabledFalse(string rawValue)
{
MtlsOptions options = MtlsOptions.FromConfiguration(Configuration(
(MtlsOptions.EnabledEnvKey, rawValue)));
Assert.False(options.Enabled);
}
[Fact]
public void FromConfiguration_MissingEnabledKey_EnabledFalse()
{
MtlsOptions options = MtlsOptions.FromConfiguration(EmptyConfiguration());
Assert.False(options.Enabled);
}
/// <summary>
/// Все env-ключи читаются в свои поля; пути обрезаются.
/// </summary>
[Fact]
public void FromConfiguration_AllEnvKeys_ParsedToOptions()
{
MtlsOptions options = MtlsOptions.FromConfiguration(Configuration(
(MtlsOptions.EnabledEnvKey, "1"),
(MtlsOptions.CaPemEnvKey, " /certs/ca.pem "),
(MtlsOptions.ServerCertPfxEnvKey, "/certs/core-server.pfx"),
(MtlsOptions.ServerCertPasswordEnvKey, "s3cret-server"),
(MtlsOptions.ClientCertPfxEnvKey, "/certs/deal-client.pfx"),
(MtlsOptions.ClientCertPasswordEnvKey, "s3cret-client")));
Assert.True(options.Enabled);
Assert.Equal("/certs/ca.pem", options.CaPem);
Assert.Equal("/certs/core-server.pfx", options.ServerCertPfx);
Assert.Equal("s3cret-server", options.ServerCertPassword);
Assert.Equal("/certs/deal-client.pfx", options.ClientCertPfx);
Assert.Equal("s3cret-client", options.ClientCertPassword);
}
// Пустая конфигурация (env-ключей mTLS нет).
private static IConfiguration EmptyConfiguration()
=> new ConfigurationBuilder().Build();
// Конфигурация из пар ключ-значение env (как их видит провайдер EnvironmentVariables).
// values: Пары (ключ конфигурации, значение).
private static IConfiguration Configuration(params (string Key, string Value)[] values)
{
Dictionary<string, string?> dictionary = values.ToDictionary(
pair => pair.Key,
pair => (string?)pair.Value);
return new ConfigurationBuilder().AddInMemoryCollection(dictionary).Build();
}
}
@@ -0,0 +1,88 @@
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="OperatorAuthStore"/> на InMemory-провайдере
/// </summary>
public sealed class OperatorAuthStoreTests
{
private const string OperatorPasswordHash = "fake-argon2-encoded-hash";
[Fact]
public async Task CreateOperator_ThenFindByLogin_ReturnsMappedRecord()
{
var store = CreateStore();
var id = Guid.NewGuid();
await store.CreateAsync(
new StoredOperatorDto(id, "root-operator", "active", OperatorPasswordHash),
CancellationToken.None);
var found = await store.FindByLoginAsync("root-operator", CancellationToken.None);
Assert.NotNull(found);
Assert.Equal(id, found!.Id);
Assert.Equal("root-operator", found.Login);
Assert.Equal("active", found.Status);
Assert.Equal(OperatorPasswordHash, found.PasswordHash);
}
[Fact]
public async Task CreateSession_ThenFindByTokenHash_ReturnsMappedSession()
{
var store = CreateStore();
var operatorId = Guid.NewGuid();
await store.CreateAsync(
new StoredOperatorDto(operatorId, "root-operator", "active", OperatorPasswordHash),
CancellationToken.None);
var expiresAt = DateTimeOffset.UtcNow.AddHours(12);
await store.CreateSessionAsync(
new OperatorSessionDto("token-hash-abc", operatorId, "root-operator", expiresAt),
CancellationToken.None);
var found = await store.FindSessionByTokenHashAsync("token-hash-abc", CancellationToken.None);
Assert.NotNull(found);
Assert.Equal("token-hash-abc", found!.TokenHash);
Assert.Equal(operatorId, found.OperatorId);
Assert.Equal("root-operator", found.Login);
Assert.Equal(expiresAt, found.ExpiresAt);
}
[Fact]
public async Task FindSessionByTokenHash_WithExpiredSession_ReturnsNull()
{
var store = CreateStore();
var operatorId = Guid.NewGuid();
await store.CreateAsync(
new StoredOperatorDto(operatorId, "root-operator", "active", OperatorPasswordHash),
CancellationToken.None);
await store.CreateSessionAsync(
new OperatorSessionDto("token-hash-expired", operatorId, "root-operator", DateTimeOffset.UtcNow.AddHours(-1)),
CancellationToken.None);
// Адаптер не возвращает протухшие сессии (зеркало AuthStore).
Assert.Null(await store.FindSessionByTokenHashAsync("token-hash-expired", CancellationToken.None));
}
[Fact]
public async Task FindByLogin_WithUnknownLogin_ReturnsNull()
{
var store = CreateStore();
Assert.Null(await store.FindByLoginAsync("no-such-operator", CancellationToken.None));
}
// Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются).
private static OperatorAuthStore CreateStore()
{
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
return new OperatorAuthStore(new DealDbContext(options));
}
}
@@ -0,0 +1,106 @@
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="RateLimitCounterStore"/> на InMemory-провайдере
/// </summary>
public sealed class RateLimitCounterStoreTests
{
// Фиксированное «сейчас» тестов (UTC).
private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero);
// Длина окна сценариев — 1 минута (как у HTTP-политик).
private static readonly TimeSpan Window = TimeSpan.FromMinutes(1);
// Ключ сценария (партиция политики).
private const string Key = "http:auth:ip:127.0.0.1";
[Fact]
public async Task IncrementAsync_FirstCall_CreatesCounterWithAmount()
{
(_, RateLimitCounterStore store) = CreateStore();
int count = await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None);
Assert.Equal(1, count);
Assert.Equal(1, await store.GetCountAsync(Key, Now, CancellationToken.None));
}
[Fact]
public async Task IncrementAsync_SameWindow_Accumulates()
{
(_, RateLimitCounterStore store) = CreateStore();
await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None);
await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None);
int count = await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None);
Assert.Equal(3, count);
}
[Fact]
public async Task IncrementAsync_NewWindow_ResetsCounter()
{
(_, RateLimitCounterStore store) = CreateStore();
DateTimeOffset nextWindow = Now.Add(Window);
await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None);
await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None);
int count = await store.IncrementAsync(Key, nextWindow, nextWindow.Add(Window), 1, CancellationToken.None);
// Смена окна сбрасывает счётчик (семантика FixedWindowRateLimiter).
Assert.Equal(1, count);
Assert.Equal(0, await store.GetCountAsync(Key, Now, CancellationToken.None));
Assert.Equal(1, await store.GetCountAsync(Key, nextWindow, CancellationToken.None));
}
[Fact]
public async Task GetCountAsync_UnknownKeyOrWindow_ReturnsZero()
{
(_, RateLimitCounterStore store) = CreateStore();
await store.IncrementAsync(Key, Now, Now.Add(Window), 1, CancellationToken.None);
Assert.Equal(0, await store.GetCountAsync("other:key", Now, CancellationToken.None));
Assert.Equal(0, await store.GetCountAsync(Key, Now.Add(Window), CancellationToken.None));
}
[Fact]
public async Task ResetAsync_RemovesCounter()
{
(_, RateLimitCounterStore store) = CreateStore();
await store.IncrementAsync(Key, Now, Now.Add(Window), 5, CancellationToken.None);
await store.ResetAsync(Key, CancellationToken.None);
Assert.Equal(0, await store.GetCountAsync(Key, Now, CancellationToken.None));
}
[Fact]
public async Task DeleteExpiredAsync_RemovesOnlyExpiredWindows()
{
(_, RateLimitCounterStore store) = CreateStore();
await store.IncrementAsync("expired", Now.AddHours(-2), Now.AddHours(-2).Add(Window), 1, CancellationToken.None);
await store.IncrementAsync("active", Now, Now.Add(Window), 1, CancellationToken.None);
int removed = await store.DeleteExpiredAsync(Now, CancellationToken.None);
Assert.Equal(1, removed);
Assert.Equal(0, await store.GetCountAsync("expired", Now.AddHours(-2), CancellationToken.None));
Assert.Equal(1, await store.GetCountAsync("active", Now, CancellationToken.None));
}
// Создаёт адаптер на свежем InMemory-контексте.
// Возвращает: Контекст и адаптер.
private static (DealDbContext Db, RateLimitCounterStore Store) CreateStore()
{
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
var db = new DealDbContext(options);
return (db, new RateLimitCounterStore(db));
}
}
@@ -0,0 +1,91 @@
using Deal.Infrastructure.Security;
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Тесты AES-256-GCM-шифра секретов
/// </summary>
public sealed class SecretCipherTests
{
private const int KeySizeBytes = 32;
private const string SomeSecret = "sk-ant-секретный-ключ-провайдера";
// Фиксированный тестовый ключ (не нулевой, байты 1..32).
private static readonly byte[] Key =
Enumerable.Range(1, KeySizeBytes).Select(index => (byte)index).ToArray();
private readonly ISecretCipher _cipher = new AesGcmSecretCipher(Key);
[Theory]
[InlineData("")]
[InlineData("простой текст")]
[InlineData("кириллица и спецсимволы: !@#$%^&*()_+=-")]
[InlineData("https://api.anthropic.com/v1?key=тоже-секрет")]
public void Decrypt_AfterEncrypt_ReturnsOriginalText(string plainText)
{
string encrypted = _cipher.Encrypt(plainText);
Assert.Equal(plainText, _cipher.Decrypt(encrypted));
}
[Fact]
public void Encrypt_EmptyText_ReturnsEmptyString()
{
Assert.Equal(string.Empty, _cipher.Encrypt(string.Empty));
}
[Fact]
public void Encrypt_NonEmptyText_ProducesTokenWithEncPrefix()
{
string token = _cipher.Encrypt(SomeSecret);
Assert.StartsWith("enc:", token);
}
[Fact]
public void Encrypt_SameTextTwice_ProducesDifferentTokensBecauseOfNonce()
{
string first = _cipher.Encrypt(SomeSecret);
string second = _cipher.Encrypt(SomeSecret);
Assert.NotEqual(first, second);
}
[Fact]
public void Decrypt_TokenWithoutEncPrefix_ReturnsEmptyString()
{
Assert.Equal(string.Empty, _cipher.Decrypt("незашифрованное значение"));
}
[Theory]
[InlineData("enc:")]
[InlineData("enc:не-base64!")]
[InlineData("enc:AAAA")]
public void Decrypt_MalformedToken_ReturnsEmptyStringWithoutException(string token)
{
Assert.Equal(string.Empty, _cipher.Decrypt(token));
}
[Fact]
public void Decrypt_TokenWithCorruptedTag_ReturnsEmptyString()
{
string token = _cipher.Encrypt(SomeSecret);
byte[] payload = Convert.FromBase64String(token[4..]);
payload[^1] ^= 0xFF; // портим последний байт тега аутентичности
string corruptedToken = "enc:" + Convert.ToBase64String(payload);
Assert.Equal(string.Empty, _cipher.Decrypt(corruptedToken));
}
[Fact]
public void Decrypt_TokenEncryptedWithAnotherKey_ReturnsEmptyString()
{
byte[] otherKey = Enumerable.Range(1, KeySizeBytes).Select(index => (byte)(index * 2)).ToArray();
ISecretCipher otherCipher = new AesGcmSecretCipher(otherKey);
string token = otherCipher.Encrypt(SomeSecret);
Assert.Equal(string.Empty, _cipher.Decrypt(token));
}
}
@@ -0,0 +1,15 @@
using Deal.Infrastructure.Persistence.Entities;
namespace Deal.Tests.Unit.Infrastructure;
public sealed class TenantEntityTests
{
[Fact]
public void TenantEntity_Defaults_AreValid()
{
var entity = new TenantEntity();
Assert.Equal("active", entity.Status);
Assert.Equal(string.Empty, entity.Name);
Assert.Equal(default(DateTimeOffset), entity.CreatedAt);
}
}
@@ -0,0 +1,299 @@
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Entities;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Microsoft.EntityFrameworkCore;
using Deal.Modules.Tenants.Application.Abstractions;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="TenantLimitStore"/> на InMemory-провайдере
/// </summary>
public sealed class TenantLimitStoreTests
{
// Фиксированное «сейчас» тестов (UTC) — детерминированный ленивый reset.
private static readonly DateTimeOffset Now = new(2026, 9, 1, 12, 0, 0, TimeSpan.Zero);
[Fact]
public async Task GetOrCreateAsync_NoRow_CreatesDefaultBudget()
{
(DealDbContext db, ITenantLimitStore store) = CreateStore();
var tenantId = Guid.NewGuid();
AddTenant(db, tenantId, TenantStatuses.Active);
TenantLimitDto first = await store.GetOrCreateAsync(tenantId, CancellationToken.None);
Assert.Equal(TokenBudgetDefaults.DefaultBudgetTokens, first.BudgetTokens);
Assert.Equal(TenantLimitPeriods.Month, first.Period);
Assert.Equal(0, first.UsedTokens);
Assert.False(first.Warned80);
Assert.False(first.NotifiedExhausted);
// Строка реально сохранена: повторный вызов возвращает ту же, дублей нет.
TenantLimitDto second = await store.GetOrCreateAsync(tenantId, CancellationToken.None);
Assert.Equal(first, second);
Assert.Single(db.TenantLimits);
}
[Fact]
public async Task GetOrCreateAsync_WithExplicitDefaults_UsesThem()
{
(DealDbContext db, ITenantLimitStore store) = CreateStore();
var tenantId = Guid.NewGuid();
AddTenant(db, tenantId, TenantStatuses.Active);
TenantLimitDto row = await store.GetOrCreateAsync(
tenantId, CancellationToken.None, new TokenLimitDefaults(BudgetTokens: 50_000, Period: TenantLimitPeriods.Day));
Assert.Equal(50_000, row.BudgetTokens);
Assert.Equal(TenantLimitPeriods.Day, row.Period);
}
[Fact]
public async Task GetStateAsync_ReturnsStatusAndAllowed()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore();
// Активный тенант, расход ниже бюджета → Allowed=true.
AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, period: TenantLimitPeriods.Month, periodStart: Now, usedTokens: 300);
BudgetStateDto active = await store.GetStateAsync(tenantId, CancellationToken.None);
Assert.Equal(TenantStatuses.Active, active.Status);
Assert.True(active.Allowed);
var suspendedTenant = Guid.NewGuid();
AddTenant(db, suspendedTenant, TenantStatuses.Suspended);
BudgetStateDto suspended = await store.GetStateAsync(suspendedTenant, CancellationToken.None);
Assert.Equal(TenantStatuses.Suspended, suspended.Status);
Assert.False(suspended.Allowed);
}
[Fact]
public async Task AddUsageAsync_WhenPeriodExpired_ResetsUsedAndStartsNewPeriod()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, periodStart: Now.AddMonths(-2), usedTokens: 700, warned80: true, notifiedExhausted: true);
BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 500, CancellationToken.None);
Assert.Equal(500, state.UsedTokens); // 700 старого периода не переносятся
Assert.False(state.Warned80);
Assert.False(state.NotifiedExhausted);
TenantLimitEntity entity = db.TenantLimits.Single(x => x.TenantId == tenantId);
Assert.Equal(Now, entity.PeriodStart);
Assert.Equal(500, entity.UsedTokens);
}
[Fact]
public async Task AddUsageAsync_WithinPeriod_Accumulates()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 300);
BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 200, CancellationToken.None);
Assert.Equal(500, state.UsedTokens);
Assert.Equal(Now, state.PeriodStart); // период не истёк — начало не сдвинуто
}
[Fact]
public async Task AddUsageAsync_Crossing80Percent_DoesNotSetWarned80()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 700);
BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 100, CancellationToken.None);
Assert.Equal(800, state.UsedTokens);
Assert.False(state.Warned80); // списание флаг не выставляет (переход наблюдает планировщик через TryMark*)
Assert.False(state.NotifiedExhausted);
Assert.True(state.Allowed); // Allowed считается от used/budget, не от флагов
}
[Fact]
public async Task AddUsageAsync_Exhaustion_DoesNotSetFlagsButDisallows()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 950);
BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 100, CancellationToken.None);
Assert.Equal(1050, state.UsedTokens);
Assert.False(state.Warned80);
Assert.False(state.NotifiedExhausted);
Assert.False(state.Allowed); // бюджет исчерпан → ИИ запрещён (гейт Task 9) — от used/budget, без флагов
}
[Fact]
public async Task AddUsageAsync_ZeroTokens_CreatesLazyRowWithoutIncrement()
{
// Нулевой расход не инкрементирует, но строка лимита заводится лениво (закрыт путь записи).
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
BudgetStateDto state = await store.AddUsageAsync(tenantId, tokens: 0, CancellationToken.None);
Assert.Equal(0, state.UsedTokens);
Assert.True(db.TenantLimits.Any(x => x.TenantId == tenantId));
}
[Fact]
public async Task UpdateBudgetAsync_AppliesNewBudgetAndPeriod_AndResetsFlags()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 900, warned80: true, notifiedExhausted: true);
BudgetStateDto state = await store.UpdateBudgetAsync(tenantId, budgetTokens: 2000, period: TenantLimitPeriods.Day, CancellationToken.None);
Assert.Equal(2000, state.BudgetTokens);
Assert.Equal(TenantLimitPeriods.Day, state.Period);
Assert.False(state.Warned80);
Assert.False(state.NotifiedExhausted);
Assert.Equal(900, state.UsedTokens); // расход периода сохраняется
}
[Fact]
public async Task UpdateBudgetAsync_InvalidArguments_Throw()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => store.UpdateBudgetAsync(tenantId, budgetTokens: -1, period: TenantLimitPeriods.Month, CancellationToken.None));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(
() => store.UpdateBudgetAsync(tenantId, budgetTokens: 1000, period: "week", CancellationToken.None));
}
[Fact]
public async Task TryMarkWarnedAsync_MarksOnceAtThreshold()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 800);
Assert.True(await store.TryMarkWarnedAsync(tenantId, CancellationToken.None));
Assert.False(await store.TryMarkWarnedAsync(tenantId, CancellationToken.None));
Assert.True(db.TenantLimits.Single(x => x.TenantId == tenantId).Warned80);
// Ниже порога флаг не выставляется.
var lowTenant = Guid.NewGuid();
AddTenant(db, lowTenant, TenantStatuses.Active);
AddLimit(db, lowTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 700);
Assert.False(await store.TryMarkWarnedAsync(lowTenant, CancellationToken.None));
Assert.False(db.TenantLimits.Single(x => x.TenantId == lowTenant).Warned80);
}
[Fact]
public async Task TryMarkNotifiedExhaustedAsync_MarksOnceAtExhaustion()
{
var tenantId = Guid.NewGuid();
(DealDbContext db, ITenantLimitStore store) = CreateStore(clock: () => Now);
AddTenant(db, tenantId, TenantStatuses.Active);
AddLimit(db, tenantId, budgetTokens: 1000, TenantLimitPeriods.Month, Now, usedTokens: 1000);
Assert.True(await store.TryMarkNotifiedExhaustedAsync(tenantId, CancellationToken.None));
Assert.False(await store.TryMarkNotifiedExhaustedAsync(tenantId, CancellationToken.None));
Assert.True(db.TenantLimits.Single(x => x.TenantId == tenantId).NotifiedExhausted);
}
/// <summary>
/// Авто-очистка: строки с завершившимся периодом сбрасываются, текущие — нет.
/// </summary>
[Fact]
public async Task ResetExpiredPeriodsAsync_ResetsOnlyExpiredPeriods()
{
(DealDbContext db, ITenantLimitStore store) = CreateStore();
var expiredTenant = Guid.NewGuid();
var currentTenant = Guid.NewGuid();
// Период месяца начат за 2 месяца назад → истёк к Now; расход и флаги должны обнулиться.
AddLimit(db, expiredTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now.AddMonths(-2),
usedTokens: 800, warned80: true);
// Текущий период (месяц не истёк) — не трогается.
AddLimit(db, currentTenant, budgetTokens: 1000, TenantLimitPeriods.Month, Now.AddDays(-1), usedTokens: 300);
int reset = await store.ResetExpiredPeriodsAsync(Now, CancellationToken.None);
Assert.Equal(1, reset);
TenantLimitEntity expired = db.TenantLimits.Single(x => x.TenantId == expiredTenant);
Assert.Equal(0, expired.UsedTokens);
Assert.False(expired.Warned80);
Assert.False(expired.NotifiedExhausted);
Assert.Equal(Now, expired.PeriodStart);
Assert.Equal(300, db.TenantLimits.Single(x => x.TenantId == currentTenant).UsedTokens);
// Идемпотентно: повторный проход на тех же данных ничего не сбрасывает.
Assert.Equal(0, await store.ResetExpiredPeriodsAsync(Now, CancellationToken.None));
}
// Создаёт адаптер на уникальной InMemory-БД с фиксированными/системными часами.
// clock: Источник «сейчас» (null — системные часы).
// Возвращает: Кортеж (контекст для seed/проверок, адаптер).
private static (DealDbContext Db, ITenantLimitStore Store) CreateStore(Func<DateTimeOffset>? clock = null)
{
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
var db = new DealDbContext(options);
ITenantLimitStore store = new TenantLimitStore(
db, TokenBudgetDefaults.Default, new TokenBudgetService(), clock ?? (() => DateTimeOffset.UtcNow));
return (db, store);
}
// Заводит строку public.tenants (статус нужен BudgetStateDto).
private static void AddTenant(
DealDbContext db,
Guid tenantId,
string status)
{
db.Tenants.Add(new TenantEntity
{
Id = tenantId,
Name = $"Тенант {tenantId:N}",
Status = status,
CreatedAt = Now,
});
db.SaveChanges();
}
// Заводит строку tenant_limits с заданными полями (сценарий «уже есть расход/флаги»).
private static void AddLimit(
DealDbContext db,
Guid tenantId,
long budgetTokens,
string period,
DateTimeOffset periodStart,
long usedTokens,
bool warned80 = false,
bool notifiedExhausted = false)
{
db.TenantLimits.Add(new TenantLimitEntity
{
TenantId = tenantId,
BudgetTokens = budgetTokens,
Period = period,
PeriodStart = periodStart,
UsedTokens = usedTokens,
Warned80 = warned80,
NotifiedExhausted = notifiedExhausted,
UpdatedAt = Now,
});
db.SaveChanges();
}
}
@@ -0,0 +1,48 @@
using Deal.Infrastructure.Persistence;
using Deal.Infrastructure.Persistence.Repositories;
using Deal.Modules.Tenants.Application.Models;
using Microsoft.EntityFrameworkCore;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Юнит-тесты EF-адаптера <see cref="TenantRepository"/> на InMemory-провайдере
/// </summary>
public sealed class TenantRepositoryTests
{
[Fact]
public async Task UpdateStatusAsync_ForExistingTenant_SetsStatusAndReturnsTrue()
{
var store = CreateStore();
var tenantId = Guid.NewGuid();
await store.CreateAsync(
new TenantRecordDto(tenantId, "Тенант", TenantStatuses.Active, DateTimeOffset.UtcNow),
CancellationToken.None);
bool updated = await store.UpdateStatusAsync(tenantId, TenantStatuses.Suspended, CancellationToken.None);
Assert.True(updated);
TenantRecordDto? tenant = await store.FindByIdAsync(tenantId, CancellationToken.None);
Assert.NotNull(tenant);
Assert.Equal(TenantStatuses.Suspended, tenant!.Status);
}
[Fact]
public async Task UpdateStatusAsync_ForUnknownTenant_ReturnsFalse()
{
var store = CreateStore();
bool updated = await store.UpdateStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None);
Assert.False(updated);
}
// Создаёт адаптер на уникальной InMemory-БД (контексты тестов не пересекаются).
private static TenantRepository CreateStore()
{
var options = new DbContextOptionsBuilder<DealDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString("N"))
.Options;
return new TenantRepository(new DealDbContext(options));
}
}
@@ -0,0 +1,194 @@
using Deal.Infrastructure.Tenancy;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Тесты пакетной миграции схем тенантов
/// </summary>
public sealed class TenantSchemaMigrationServiceTests
{
// Тенант сценария A.
private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111");
// Тенант сценария B.
private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222");
[Fact]
public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary()
{
var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner());
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
Assert.True(summary.Ok);
Assert.Equal(0, summary.Total);
Assert.Equal(0, summary.Migrated);
Assert.Empty(summary.FailedSchemas);
}
[Fact]
public async Task MigrateAllAsync_ProvisionsEveryTenantSchema()
{
var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
Assert.True(summary.Ok);
Assert.Equal(2, summary.Total);
Assert.Equal(2, summary.Migrated);
Assert.Equal(0, summary.Failed);
Assert.Equal(
new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal),
provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal));
}
[Fact]
public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed()
{
string failingSchema = $"tenant_{TenantA:N}";
var provisioner = new FailingTenantProvisioner(failingSchema);
var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
Assert.False(summary.Ok);
Assert.Equal(2, summary.Total);
Assert.Equal(1, summary.Migrated);
Assert.Equal(1, summary.Failed);
Assert.Equal(new[] { failingSchema }, summary.FailedSchemas);
// Уцелевшая схема мигрирована, сбойная — не попала в список успешных.
Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames);
}
[Fact]
public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant()
{
var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository(Records(5));
var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(4, 2, CancellationToken.None);
Assert.True(summary.Ok);
Assert.Equal(5, summary.Total);
Assert.Equal(5, summary.Migrated);
Assert.Equal(0, summary.Failed);
Assert.Equal(5, provisioner.ProvisionedSchemaNames.Count);
Assert.Equal(5, provisioner.ProvisionedSchemaNames.Distinct().Count());
// Страницы запрошены последовательно с устойчивым offset: 0, 2, 4 (страница на 4 неполная — обход остановлен).
Assert.Equal(
new (int Offset, int Limit)[] { (0, 2), (2, 2), (4, 2) },
repository.PageRequests);
}
[Fact]
public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant()
{
var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository(Records(4));
var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(2, 1, CancellationToken.None);
Assert.True(summary.Ok);
Assert.Equal(4, summary.Total);
Assert.Equal(4, summary.Migrated);
Assert.Equal(4, provisioner.ProvisionedSchemaNames.Count);
// По одной записи на страницу: 0,1,2,3, затем пустая страница завершает обход.
Assert.Equal(5, repository.PageRequests.Count);
Assert.Equal(0, repository.PageRequests[0].Offset);
Assert.Equal(3, repository.PageRequests[3].Offset);
}
[Fact]
public async Task MigrateAllAsync_SchemaFailsWithinPage_ContinuesOthers()
{
TenantRecordDto[] records = Records(3);
string failingSchema = $"tenant_{records[0].Id:N}";
var provisioner = new FailingTenantProvisioner(failingSchema);
var repository = new FakeTenantRepository(records);
var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(1, 2, CancellationToken.None);
Assert.False(summary.Ok);
Assert.Equal(3, summary.Total);
Assert.Equal(2, summary.Migrated);
Assert.Equal(1, summary.Failed);
Assert.Equal(new[] { failingSchema }, summary.FailedSchemas);
// Уцелевшие схемы обеих страниц провижинены, сбойная — нет.
Assert.Equal(2, provisioner.ProvisionedSchemaNames.Count);
}
[Fact]
public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum()
{
var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository(Records(3));
var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(0, 0, CancellationToken.None);
Assert.True(summary.Ok);
Assert.Equal(3, summary.Total);
Assert.Equal(3, summary.Migrated);
// Кламп pageSize → 1: по одной записи на страницу (плюс пустая на завершение).
Assert.All(repository.PageRequests, request => Assert.Equal(1, request.Limit));
}
[Fact]
public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant()
{
var provisioner = new FakeTenantProvisioner();
var repository = new FakeTenantRepository(
TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None);
Assert.True(summary.Ok);
Assert.Equal(2, summary.Total);
Assert.Equal(2, summary.Migrated);
}
// Создаёт сервис на фейках (логирование не проверяется).
// repository: Реестр тенантов.
// provisioner: Провижинер схем.
// Возвращает: Тестируемый сервис.
private static TenantSchemaMigrationService NewService(ITenantRepository repository, ITenantProvisioner provisioner)
=> new(repository, provisioner, NullLogger<TenantSchemaMigrationService>.Instance);
// Строка реестра тенантов для сценария.
// id: Id тенанта.
// name: Имя тенанта.
// Возвращает: Запись реестра.
private static TenantRecordDto TenantRecord(Guid id, string name)
=> new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow);
// Последовательность тенантов с детерминированными Id (для проверки шардирования).
// count: Сколько записей создать.
// Возвращает: Записи реестра в порядке обхода.
private static TenantRecordDto[] Records(int count)
{
var records = new TenantRecordDto[count];
for (int index = 0; index < count; index++)
{
var id = Guid.Parse($"33333333-3333-3333-3333-{index + 1:D12}");
records[index] = TenantRecord(id, $"T{index + 1}");
}
return records;
}
}
@@ -0,0 +1,22 @@
using Deal.Infrastructure.Migrations;
namespace Deal.Tests.Unit.Infrastructure;
public sealed class TenantSchemaMigratorTests
{
[Fact]
public void CreateSchemaSql_IsEscaped()
{
string sql = TenantSchemaMigrator.CreateSchemaSql("tenant_abc");
Assert.Contains("CREATE SCHEMA IF NOT EXISTS \"tenant_abc\"", sql);
Assert.DoesNotContain("; DROP", sql);
}
[Fact]
public void CreateSchemaSql_EscapesQuotes()
{
string sql = TenantSchemaMigrator.CreateSchemaSql("tenant_a\"b");
Assert.Contains("\"tenant_a\"\"b\"", sql);
Assert.DoesNotContain("\"tenant_a\"b\"", sql);
}
}
@@ -0,0 +1,18 @@
using Deal.Infrastructure.Persistence.Entities;
namespace Deal.Tests.Unit.Infrastructure;
/// <summary>
/// Лёгкий тест сущности настройки тенанта
/// </summary>
public sealed class TenantSettingEntityTests
{
[Fact]
public void TenantSettingEntity_Defaults_AreValid()
{
var entity = new TenantSettingEntity();
Assert.Equal(string.Empty, entity.Key);
Assert.Equal(string.Empty, entity.ValueJson);
Assert.Equal(default(DateTimeOffset), entity.UpdatedAt);
}
}
@@ -0,0 +1,118 @@
using Deal.Modules.Cards;
using Deal.Modules.Cards.Application.Abstractions;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
namespace Deal.Tests.Unit.Modules.Cards;
/// <summary>
/// Тесты каркаса единой карточки
/// </summary>
public sealed class CardsDomainTests
{
[Fact]
public void ICardsModule_IsPublicInterface()
{
Assert.True(typeof(ICardsModule).IsPublic);
Assert.True(typeof(ICardsModule).IsInterface);
}
[Fact]
public void CardIds_ExposeServiceContainersAndCardPrefix()
{
Assert.Equal("c_", CardIds.CardPrefix);
Assert.Equal("inbox", CardIds.Inbox);
Assert.Equal("archive", CardIds.Archive);
Assert.Equal("trash", CardIds.Trash);
}
[Fact]
public void CardsDefaultContainers_ContainsAllStagesInOrder()
{
var ids = CardsDefaultContainers.All.Select(c => c.Id).ToList();
Assert.Equal(
new[]
{
CardsDefaultContainers.Planned,
CardsDefaultContainers.Reply,
CardsDefaultContainers.Agree,
CardsDefaultContainers.Work,
CardsDefaultContainers.Review,
CardsDefaultContainers.Ready,
CardsDefaultContainers.Hold,
CardsDefaultContainers.Finished,
CardsDefaultContainers.Rejected,
},
ids);
}
[Fact]
public void CardsDefaultContainers_TerminalStages_AreFinishedAndRejectedOnly()
{
var terminal = CardsDefaultContainers.All.Where(c => c.IsTerminal).Select(c => c.Id).ToList();
Assert.Equal(
new[] { CardsDefaultContainers.Finished, CardsDefaultContainers.Rejected },
terminal);
}
[Fact]
public void CardsDefaultContainers_Contains_MatchesCatalog()
{
Assert.True(CardsDefaultContainers.Contains(CardsDefaultContainers.Work));
Assert.True(CardsDefaultContainers.Contains(CardsDefaultContainers.Rejected));
Assert.False(CardsDefaultContainers.Contains("not-a-stage"));
}
[Fact]
public void Card_ImplementsCoreAndModuleRoles()
{
Card card = new() { Id = "c_1", Title = "Заголовок", Source = new SourceRef { Kind = SourceKinds.Local } };
Assert.IsAssignableFrom<ICard>(card);
Assert.IsAssignableFrom<IContentCard>(card);
Assert.IsAssignableFrom<IBudgetedCard>(card);
Assert.IsAssignableFrom<IContactCard>(card);
Assert.IsAssignableFrom<IAttributedCard>(card);
Assert.IsAssignableFrom<ICommentableCard>(card);
Assert.IsAssignableFrom<ILinkCard>(card);
Assert.IsAssignableFrom<IFileCard>(card);
Assert.IsAssignableFrom<ITzCard>(card);
Assert.IsAssignableFrom<ITraceableCard>(card);
Assert.IsAssignableFrom<IRemindableCard>(card);
Assert.IsAssignableFrom<ILocatedCard>(card);
}
[Fact]
public void Card_NewCard_DefaultsToInboxAndEmptyModules()
{
Card card = new() { Id = "c_2", Title = "Новая", Source = new SourceRef { Kind = SourceKinds.Local } };
Assert.Equal(CardIds.Inbox, card.ContainerId);
Assert.True(card.IsNew);
Assert.Empty(card.Attributes);
Assert.Empty(card.Comments);
Assert.Empty(card.Links);
Assert.Empty(card.Files);
Assert.Empty(card.Contacts);
Assert.Empty(card.History);
Assert.Null(card.Budget);
Assert.Null(card.Reminder);
Assert.Equal(string.Empty, card.Summary);
Assert.Equal(string.Empty, card.TzText);
}
[Fact]
public void Card_Source_IsStored()
{
Card card = new()
{
Id = "c_3",
Title = "Из источника",
Source = new SourceRef { Kind = "external", ExternalId = "42", DisplayName = "Канал" },
};
Assert.Equal("external", card.Source.Kind);
Assert.Equal("42", card.Source.ExternalId);
Assert.Equal("Канал", card.Source.DisplayName);
}
}
@@ -0,0 +1,855 @@
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Tests.Unit.Modules.Cards;
/// <summary>
/// In-memory реализация <see cref="ICardStore"/> для unit-тестов сервисов единого домена карточки.
/// </summary>
public class FakeKanjStore : ICardStore
{
private readonly List<ContainerDto> _boards = [];
private readonly List<CardDto> _cards = [];
private readonly List<CardMoveDto> _moves = [];
private readonly List<(string Query, int Limit)> _searchCalls = [];
private readonly HashSet<string> _firedById = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _archivedAtById = new(StringComparer.Ordinal);
/// <summary>
/// Доски фейка (копия на момент обращения) — проверки BoardsServiceTests и LocalColumnSuggesterTests.
/// </summary>
public IReadOnlyList<ContainerDto> Boards => _boards.ToList();
/// <summary>
/// Карточки фейка как тройки
/// </summary>
public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards =>
_cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList();
/// <summary>
/// Полные карточки фейка
/// </summary>
public IReadOnlyList<CardDto> CardDtos => _cards.ToList();
/// <summary>
/// Флаг «сбой записи карточки»
/// </summary>
public bool FailAddCard { get; set; }
/// <summary>
/// Записи журнала CardMoves
/// </summary>
public IReadOnlyList<CardMoveDto> Moves => _moves.ToList();
/// <summary>
/// Вызовы SearchCardsAsync как пары
/// </summary>
public IReadOnlyList<(string Query, int Limit)> SearchCalls => _searchCalls.ToList();
/// <summary>
/// Кладёт доску напрямую
/// </summary>
/// <param name="board">Доска как если бы была сохранена в БД.</param>
public void SeedBoard(ContainerDto board)
{
_boards.Add(board);
}
/// <summary>
/// Кладёт карточку в колонку.
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="col">Колонка (inbox/доска).</param>
public void AddCard(string cardId, string col)
{
SeedCard(new CardDto { Id = cardId, Col = col });
}
/// <summary>
/// Кладёт полную карточку.
/// </summary>
/// <param name="card">Карточка как если бы была сохранена в БД (комментарии — приложенным массивом).</param>
public void SeedCard(CardDto card)
{
_cards.Add(card);
}
// ── Контейнеры (колонки/стадии/зоны) ─────────────────────────────────
/// <inheritdoc />
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct)
{
IReadOnlyList<ContainerDto> ordered = _boards
.Where(board => space is null || board.Space == space)
.OrderBy(board => board.Suggested)
.ThenBy(board => board.Order)
.ToList();
return Task.FromResult(ordered);
}
/// <inheritdoc />
public Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct)
{
return Task.FromResult(_boards.FirstOrDefault(board => board.Id == containerId));
}
/// <inheritdoc />
public Task CreateContainerAsync(ContainerDto container, CancellationToken ct)
{
_boards.Add(container);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct)
{
int index = _boards.FindIndex(item => item.Id == container.Id);
_boards[index] = container;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct)
{
List<CardDto> moved = _cards.Where(card => card.Col == containerId).ToList();
for (int i = 0; i < _cards.Count; i++)
{
if (_cards[i].Col == containerId)
{
_cards[i] = _cards[i] with { Col = KanbanColumns.Inbox, IsNew = true, PrevCol = KanbanColumns.Inbox };
}
}
_boards.RemoveAll(board => board.Id == containerId);
return Task.FromResult(moved.Count);
}
/// <inheritdoc />
public Task ReorderContainersAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct)
{
for (int i = 0; i < containerIds.Count; i++)
{
string containerId = containerIds[i];
int index = _boards.FindIndex(board => board.Id == containerId && board.Space == space);
if (index >= 0)
{
_boards[index] = _boards[index] with { Order = i };
}
}
return Task.CompletedTask;
}
// ── Карточки ─────────────────────────────────────────────────────────
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct)
{
IEnumerable<CardDto> result = query.Col is null
? _cards
: _cards.Where(card => card.Col == query.Col);
return Task.FromResult<IReadOnlyList<CardDto>>(
result.OrderByDescending(card => card.ReceivedAtMs).ToList());
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(
string q,
int limit,
CancellationToken ct)
{
_searchCalls.Add((q, limit));
string lowered = q.Trim().ToLowerInvariant();
if (lowered.Length == 0)
{
return Task.FromResult<IReadOnlyList<CardDto>>(Array.Empty<CardDto>());
}
IReadOnlyList<CardDto> result = _cards
.Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| (card.Content.Text ?? string.Empty).ToLowerInvariant().Contains(lowered, StringComparison.Ordinal))
.OrderByDescending(card => card.ReceivedAtMs)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<CardDto>>(result);
}
/// <inheritdoc />
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return Task.FromResult(_cards.FirstOrDefault(card => card.Id == cardId));
}
/// <inheritdoc />
public Task<CardDto?> GetCardBySourceAsync(
SourceRef source,
CancellationToken ct)
{
if (string.IsNullOrEmpty(source.Kind))
{
return Task.FromResult<CardDto?>(null);
}
string externalId = source.ExternalId ?? string.Empty;
string originRef = source.OriginRef ?? string.Empty;
CardDto? match = _cards
.Where(card => card.Source.Kind == source.Kind
&& (card.Source.ExternalId ?? string.Empty) == externalId
&& (card.Source.OriginRef ?? string.Empty) == originRef)
.OrderByDescending(card => card.ReceivedAtMs)
.FirstOrDefault();
return Task.FromResult(match);
}
/// <inheritdoc />
public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
{
if (FailAddCard)
{
throw new InvalidOperationException("Тестовый сбой записи карточки (FailAddCard).");
}
_cards.Add(new CardDto
{
Id = snapshot.Id,
Col = snapshot.Col,
IsNew = snapshot.IsNew,
Local = snapshot.Local,
IsVacancy = snapshot.IsVacancy,
IsVacancyKnown = snapshot.IsVacancyKnown,
Title = snapshot.Title,
Summary = snapshot.Summary,
Stack = snapshot.Stack,
Budget = snapshot.BudgetCur.Length == 0
? null
: new CardBudgetDto(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur),
Converted = snapshot.ConvCur.Length == 0
? null
: new CardBudgetDto(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur),
Contact = snapshot.Contact,
Contacts = snapshot.Contacts,
Source = snapshot.Source,
Content = snapshot.Content,
Time = "только что",
ReceivedAtMs = snapshot.ReceivedAt.ToUnixTimeMilliseconds(),
PrevCol = snapshot.PrevCol,
MatchHits = snapshot.MatchHits,
TzText = snapshot.TzText,
Comments = snapshot.Comments,
History = snapshot.History,
CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == update.CardId);
if (index < 0)
{
return Task.CompletedTask;
}
_cards[index] = _cards[index] with
{
Col = update.Col,
IsNew = update.IsNew,
PrevCol = update.PrevCol ?? _cards[index].PrevCol,
MatchHits = update.MatchHits,
};
if (update.ArchivedAt is { } archivedAt)
{
_archivedAtById[update.CardId] = archivedAt;
}
else
{
_archivedAtById.Remove(update.CardId);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == update.CardId);
if (index < 0)
{
return Task.FromResult(false);
}
_cards[index] = _cards[index] with
{
Col = update.Col,
IsNew = update.IsNew,
IsVacancy = update.IsVacancy,
IsVacancyKnown = update.IsVacancyKnown,
Title = update.Title,
Summary = update.Summary,
Stack = update.Stack,
Budget = update.Budget,
Converted = update.Converted,
Contact = update.Contact,
Contacts = update.Contacts,
MatchHits = update.MatchHits,
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task UpdateSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
{
for (int i = 0; i < _cards.Count; i++)
{
bool matches = cardId is not null
? _cards[i].Id == cardId
: col is null || _cards[i].Col == col;
if (matches && _cards[i].IsNew)
{
_cards[i] = _cards[i] with { IsNew = false };
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteForeverAsync(string cardId, CancellationToken ct)
{
// Удаляется карточка (с комментариями — они часть карточки в фейке); журнал CardMoves не трогаем.
_cards.RemoveAll(card => card.Id == cardId);
_archivedAtById.Remove(cardId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearColAsync(string col, CancellationToken ct)
{
List<string> removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList();
_cards.RemoveAll(card => card.Col == col);
foreach (string cardId in removedIds)
{
_archivedAtById.Remove(cardId);
}
return Task.FromResult(removedIds.Count);
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
{
IReadOnlyDictionary<string, CardColumnCountDto> result = _cards
.GroupBy(card => card.Col)
.ToDictionary(
group => group.Key,
group => new CardColumnCountDto(group.Count(), group.Count(card => card.IsNew)));
return Task.FromResult(result);
}
// ── Комментарии (LeadComments) ────────────────────────────────────────
/// <inheritdoc />
public Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
{
CardDto? card = _cards.FirstOrDefault(item => item.Id == cardId);
return Task.FromResult(card?.Comments ?? Array.Empty<CardCommentDto>());
}
/// <inheritdoc />
public Task AddCommentAsync(
string commentId,
string cardId,
string by,
string text,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.CompletedTask;
}
// Метка времени свежего комментария — «только что» (адаптер считает HumanAge от CreatedAt = UtcNow).
CardCommentDto comment = new(commentId, by, text, "только что");
IReadOnlyList<CardCommentDto> updated = _cards[index].Comments.Concat(new[] { comment }).ToList();
_cards[index] = _cards[index] with { Comments = updated };
return Task.CompletedTask;
}
// ── Журнал CardMoves (learning_log) ───────────────────────────────────
/// <inheritdoc />
public Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
{
_moves.Add(move);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> CountMovesAsync(CancellationToken ct) => Task.FromResult(_moves.Count);
/// <inheritdoc />
public Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
{
var examples = new List<AiMarkupExampleDto>();
for (int index = _moves.Count - 1; index >= 0 && examples.Count < limit; index--)
{
CardMoveDto move = _moves[index];
if (move.Action != "move" && move.Action != "restore")
{
continue;
}
if (move.ToCol is null || move.ToCol == KanbanColumns.Trash || move.ToCol == KanbanColumns.Archive)
{
continue;
}
CardDto? card = _cards.FirstOrDefault(item => item.Id == move.LeadId);
string sourceText = card?.Content.Text ?? string.Empty;
if (card is null || sourceText.Length == 0)
{
continue;
}
examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol));
}
return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples);
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{
var boardIds = new HashSet<string>(_boards.Select(board => board.Id), StringComparer.Ordinal);
IReadOnlyList<string> result = _cards
.Where(card => (card.Col == KanbanColumns.Inbox || boardIds.Contains(card.Col))
&& ReceivedAtOf(card) < receivedBeforeUtc)
.Select(card => card.Id)
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<int> ArchiveAsync(
IReadOnlyList<string> cardIds,
DateTimeOffset archivedAt,
CancellationToken ct)
{
if (cardIds.Count == 0)
{
return Task.FromResult(0);
}
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
int archived = 0;
for (int i = 0; i < _cards.Count; i++)
{
if (!ids.Contains(_cards[i].Id))
{
continue;
}
_cards[i] = _cards[i] with
{
Col = KanbanColumns.Archive,
IsNew = false,
MatchHits = Array.Empty<MatchHitDto>(),
};
_archivedAtById[_cards[i].Id] = archivedAt;
archived++;
}
return Task.FromResult(archived);
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
{
IReadOnlyList<string> result = _cards
.Where(card => card.Col == KanbanColumns.Archive
&& _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt)
&& archivedAt < archivedBeforeUtc)
.Select(card => card.Id)
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
{
IReadOnlyList<string> result = _cards
.Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc)
.Select(card => card.Id)
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{
// Жёсткое удаление пачки (как KanbanStore.PurgeAsync): комментарии — часть карточки, журнал не трогаем.
if (cardIds.Count == 0)
{
return Task.FromResult(0);
}
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
int removed = _cards.RemoveAll(card => ids.Contains(card.Id));
foreach (string cardId in ids)
{
_archivedAtById.Remove(cardId);
}
return Task.FromResult(removed);
}
/// <summary>
/// Задаёт метку архивации карточки
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="archivedAt">Метка архивации (когда карточка ушла в архив).</param>
public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt;
/// <summary>
/// Метка архивации карточки — проверка archived_at после автоархива тика.
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <returns>Метка архивации либо null — карточки нет/не архивирована/метка сброшена.</returns>
public DateTimeOffset? ArchivedAtOf(string cardId)
{
return _archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null;
}
// Момент получения карточки из её epoch-ms (как маппинг адаптера ReceivedAt → ReceivedAtMs).
// card: Карточка.
// Возвращает: ReceivedAt карточки как UTC-момент.
private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs);
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
{
IReadOnlyList<CardDto> result = _cards
.Where(card => card.Budget is not null
&& card.Col != KanbanColumns.Archive
&& card.Col != KanbanColumns.Trash)
.OrderByDescending(card => card.ReceivedAtMs)
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task UpdateConversionAsync(
string cardId,
double? convFrom,
double? convTo,
string convCur,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index >= 0)
{
_cards[index] = _cards[index] with
{
Converted = convCur.Length == 0
? null
: new CardBudgetDto(convFrom, convTo, convCur),
};
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
{
IEnumerable<CardDto> query = containerId is null
? _cards.Where(card => CardsDefaultContainers.Contains(card.Col))
: _cards.Where(card => card.Col == containerId);
return Task.FromResult<IReadOnlyList<CardDto>>(
query.OrderByDescending(card => card.UpdatedAtMs).ToList());
}
/// <inheritdoc />
public Task<bool> PatchCardAsync(
string cardId,
CardPatch patch,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
}
CardDto card = _cards[index];
if (patch.Title is not null)
{
card = card with { Title = patch.Title };
}
if (patch.Summary is not null)
{
card = card with { Summary = patch.Summary };
}
if (patch.Contact is not null)
{
card = card with { Contact = patch.Contact };
}
if (patch.TzText is not null)
{
card = card with { TzText = patch.TzText };
}
if (patch.Stack is not null)
{
card = card with { Stack = patch.Stack.ToList() };
}
if (patch.Budget is not null)
{
card = card with { Budget = patch.Budget.Cur.Length == 0 ? null : patch.Budget };
}
if (patch.Comments is not null)
{
card = card with { Comments = patch.Comments.ToList() };
}
if (patch.Links is not null)
{
card = card with { Links = patch.Links.ToList() };
}
if (patch.Files is not null)
{
card = card with { Files = patch.Files.ToList() };
}
_cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> AddLinkAsync(
string cardId,
CardLinkDto link,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
}
_cards[index] = _cards[index] with
{
Links = [.. _cards[index].Links, link],
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
}
_cards[index] = _cards[index] with
{
Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> AddFileAsync(
string cardId,
CardFileDto file,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
}
_cards[index] = _cards[index] with
{
Files = [.. _cards[index].Files, file],
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
}
_cards[index] = _cards[index] with
{
Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> MoveCardStageAsync(
string cardId,
string containerId,
CardHistoryDto historyEntry,
long atMs,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
}
CardDto card = _cards[index];
_firedById.Remove(cardId);
_cards[index] = card with
{
Col = containerId,
Reminder = null,
UpdatedAtMs = atMs,
History = [.. card.History, historyEntry],
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.CompletedTask;
}
_firedById.Remove(cardId);
_cards[index] = _cards[index] with
{
Reminder = new CardReminderDto(atMs),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ClearReminderAsync(string cardId, CancellationToken ct)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index >= 0)
{
_cards[index] = _cards[index] with { Reminder = null };
}
_firedById.Remove(cardId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearStageAsync(string containerId, CancellationToken ct)
{
return Task.FromResult(_cards.RemoveAll(card => card.Col == containerId));
}
/// <inheritdoc />
public virtual Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
long nowMs = now.ToUnixTimeMilliseconds();
IReadOnlyList<CardReminderDueDto> due = _cards
.Where(card => card.Col == CardsDefaultContainers.Hold
&& card.Reminder is not null
&& !_firedById.Contains(card.Id)
&& card.Reminder.At <= nowMs)
.OrderBy(card => card.Reminder!.At)
.Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col))
.ToList();
return Task.FromResult(due);
}
/// <inheritdoc />
public Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
{
foreach (string cardId in cardIds)
{
if (_cards.Any(card => card.Id == cardId && card.Reminder is not null))
{
_firedById.Add(cardId);
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
long nowMs = now.ToUnixTimeMilliseconds();
int cleared = 0;
for (int i = 0; i < _cards.Count; i++)
{
if (_cards[i].Reminder is { } reminder && reminder.At <= nowMs)
{
_firedById.Remove(_cards[i].Id);
_cards[i] = _cards[i] with { Reminder = null };
cleared++;
}
}
return Task.FromResult(cleared);
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
{
IReadOnlyList<CardDto> result = _cards
.Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty)
.OrderByDescending(card => card.ReceivedAtMs)
.ToList();
return Task.FromResult(result);
}
}
@@ -0,0 +1,118 @@
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// Тесты DiscoveryBanGuard — суточный лимит авто-вступлений, flood-день, стоп-кран.
/// </summary>
public sealed class DiscoveryBanGuardTests
{
// Фиксированный «сейчас» теста (UTC-полдень; границы суток далеко от краёв).
private static readonly DateTimeOffset Now = DateTimeOffset.Parse("2026-09-07T12:34:56Z");
// Начало UTC-суток Now (для seed-строк настроек/логов).
private static readonly DateTimeOffset TodayStart = new(Now.Year, Now.Month, Now.Day, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task CanAutoJoin_NoJoinsAndDefaults_Allowed()
{
(DiscoveryBanGuard guard, _, _) = Create();
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
Assert.False(await guard.GlobalPausedAsync(CancellationToken.None));
}
[Fact]
public async Task CanAutoJoin_LimitReachedToday_Blocked()
{
(DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create();
for (int i = 0; i < 50; i++)
{
store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
}
Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None));
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
FakeDiscoveryStore yesterdayStore = new(() => Now);
var settings = new FakeSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore, settings, () => Now);
for (int i = 0; i < 49; i++)
{
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
}
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddDays(-1));
Assert.True(await secondGuard.CanAutoJoinAsync(CancellationToken.None));
}
[Fact]
public async Task CountsOnlyJoinAutoEventsToday()
{
(DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create();
store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(1));
store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(2));
store.SeedLog(DiscoveryLogEvents.JoinManual, TodayStart.AddHours(3)); // ручные — вне квоты
store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddDays(-1)); // вчера — вне квоты
Assert.Equal(2, await guard.JoinsTodayAutoAsync(CancellationToken.None));
}
[Fact]
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
await guard.NoteFloodAsync(CancellationToken.None);
Assert.True(await guard.FloodTodayAsync(CancellationToken.None));
Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None));
Assert.Equal(TodayStart.ToUnixTimeMilliseconds().ToString(), settings.GetStoredJson(SettingsKeys.DiscFloodDay));
}
[Fact]
public async Task FloodYesterday_DoesNotBlockToday()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
}
[Fact]
public async Task GlobalPaused_BlocksAutoJoins()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscPaused, "true");
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None));
}
[Fact]
public async Task CustomJoinLimit_AppliedWhenSettingStored()
{
(DiscoveryBanGuard guard, FakeDiscoveryStore store, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscJoinLimit, "2");
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(1));
store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(2));
Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None));
}
// Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время).
private static (DiscoveryBanGuard Guard, FakeDiscoveryStore Store, FakeSettingsStore Settings) Create()
{
var store = new FakeDiscoveryStore(() => Now);
var settings = new FakeSettingsStore();
var guard = new DiscoveryBanGuard(store, settings, () => Now);
return (guard, store, settings);
}
}
@@ -0,0 +1,73 @@
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// Тесты DiscoveryBlacklistService — добавление с upsert-перезаписью, снятие, список.
/// </summary>
public sealed class DiscoveryBlacklistServiceTests
{
[Fact]
public async Task Add_EmptyName_UsesDialogId()
{
(DiscoveryBlacklistService service, FakeDiscoveryStore store) = Create();
DiscoveryBlacklistDto entry = await service.AddAsync("-1001", "", "отклонён", CancellationToken.None);
Assert.Equal("-1001", entry.Name); // пустое имя → DialogId (python L575)
Assert.Equal("отклонён", entry.Reason);
Assert.Equal("-1001", Assert.Single(store.Blacklist).DialogId);
}
[Fact]
public async Task Add_OverwriteUpdatesNameAndReason_KeepsCreatedAt()
{
long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryBlacklistService(store);
// Первая запись источника — CreatedAt фиксируется (t=1000).
DiscoveryBlacklistDto first = await service.AddAsync("-1001", "Первое имя", "отклонено вручную", CancellationToken.None);
DiscoveryBlacklistDto overwritten = await service.AddAsync("-1001", "Второе имя", "отклонён повторно", CancellationToken.None);
Assert.Equal("Второе имя", overwritten.Name);
Assert.Equal("отклонён повторно", overwritten.Reason);
Assert.Equal(first.CreatedAt, overwritten.CreatedAt);
Assert.Single(store.Blacklist);
}
[Fact]
public async Task Remove_DeletesRow()
{
(DiscoveryBlacklistService service, FakeDiscoveryStore store) = Create();
await service.AddAsync("-1001", "Канал", "причина", CancellationToken.None);
await service.RemoveAsync("-1001", CancellationToken.None);
Assert.Empty(store.Blacklist);
}
[Fact]
public async Task List_ReturnsNewestFirst()
{
long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryBlacklistService(store);
await service.AddAsync("-1001", "Старый", "r1", CancellationToken.None); // t=1000
await service.AddAsync("-1002", "Новый", "r2", CancellationToken.None); // t=2000
IReadOnlyList<DiscoveryBlacklistDto> items = await service.ListAsync(CancellationToken.None);
Assert.Equal(new[] { "-1002", "-1001" }, items.Select(entry => entry.DialogId)); // новые первыми (L588)
}
// Создаёт связку blacklist-сервиса + фейк.
private static (DiscoveryBlacklistService Service, FakeDiscoveryStore Store) Create()
{
var store = new FakeDiscoveryStore();
return (new DiscoveryBlacklistService(store), store);
}
}
@@ -0,0 +1,400 @@
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// Тесты DiscoveryCandidatesService — add с исключениями, set_candidate, review, mark_joined/rejected.
/// </summary>
public sealed class DiscoveryCandidatesServiceTests
{
[Fact]
public async Task Add_NewSource_CreatesNewCandidateAndBumpsFound()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_1", "-1001", "Канал", "@canal", DiscoveryCandidateKinds.Group, "#38bdf8", CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal("-1001", candidate!.DialogId);
Assert.Equal(DiscoveryCandidateStatuses.New, candidate.Status);
Assert.Equal(DiscoveryCandidateKinds.Group, candidate.Kind);
Assert.False(candidate.AutoJoined);
Assert.Null(candidate.Participants);
Assert.Null(candidate.LangRu);
Assert.Empty(candidate.Marks);
Assert.Empty(candidate.Topics);
Assert.Equal(1, store.Tasks.Single().Found); // bump found (python L451)
Assert.Single(store.Candidates);
}
[Fact]
public async Task Add_EmptyNameAndKind_UsesDefaults()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_1", "-1001", " ", "", "", "", CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal("-1001", candidate!.Name); // пустое имя → DialogId
Assert.Equal(DiscoveryCandidateKinds.Channel, candidate.Kind); // пустой kind → channel
Assert.Equal("#666", candidate.Hue);
}
[Fact]
public async Task Add_AlreadyMonitored_SkipsWithLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedMonitored("-1001");
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_1", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
Assert.Null(candidate);
Assert.Empty(store.Candidates);
Assert.Equal(0, store.Tasks.Single().Found);
DiscoveryLogDto skip = Assert.Single(store.Log);
Assert.Equal(DiscoveryLogEvents.Skip, skip.Event);
Assert.Equal("пропущен -1001: источник уже мониторится (мы состоим)", skip.Text);
}
[Fact]
public async Task Add_Blacklisted_SkipsWithLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
await store.UpsertBlacklistAsync("-1001", "Канал", "отклонён вручную", CancellationToken.None);
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_1", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
Assert.Null(candidate);
DiscoveryLogDto skip = Assert.Single(store.Log);
Assert.Equal("пропущен -1001: источник в чёрном списке", skip.Text);
}
[Fact]
public async Task Add_AlreadyActiveCandidate_SkipsWithLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Review));
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_1", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
Assert.Null(candidate);
Assert.Single(store.Candidates);
DiscoveryLogDto skip = Assert.Single(store.Log);
Assert.Equal("пропущен -1001: кандидат уже есть (статус review)", skip.Text);
}
[Fact]
public async Task Add_StaleRejectedRow_IsReplacedByNewCandidate()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Rejected));
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_1", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal(DiscoveryCandidateStatuses.New, candidate!.Status);
DiscoveryCandidateDto stored = Assert.Single(store.Candidates);
Assert.Equal(DiscoveryCandidateStatuses.New, stored.Status);
Assert.Equal(1, store.Tasks.Single().Found);
}
[Fact]
public async Task Add_MissingTask_ReturnsNullWithoutLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
Assert.Null(candidate);
Assert.Empty(store.Candidates);
Assert.Empty(store.Log);
}
[Fact]
public async Task MarkJoined_Auto_SetsJoinedCountersAndLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
DiscoveryCandidateDto? candidate = await service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal(DiscoveryCandidateStatuses.Joined, candidate!.Status);
Assert.True(candidate.AutoJoined);
Assert.Equal(1, store.Tasks.Single().Joined); // счётчик joined (python L535)
DiscoveryLogDto join = Assert.Single(store.Log);
Assert.Equal(DiscoveryLogEvents.JoinAuto, join.Event);
Assert.Equal("вступили в -1001", join.Text);
}
[Fact]
public async Task MarkJoined_Manual_LogsJoinManual()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
DiscoveryCandidateDto? candidate = await service.MarkJoinedAsync("-1001", auto: false, CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal(DiscoveryCandidateStatuses.Joined, candidate!.Status);
Assert.False(candidate!.AutoJoined);
Assert.Equal(DiscoveryLogEvents.JoinManual, Assert.Single(store.Log).Event);
}
[Fact]
public async Task MarkJoined_AlreadyJoined_IsIdempotent()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Joined));
DiscoveryCandidateDto? candidate = await service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal(DiscoveryCandidateStatuses.Joined, candidate!.Status);
Assert.Equal(0, store.Tasks.Single().Joined);
Assert.Empty(store.Log);
}
[Fact]
public async Task MarkJoined_MissingCandidate_ReturnsNull()
{
(DiscoveryCandidatesService service, _) = Create();
DiscoveryCandidateDto? candidate = await service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None);
Assert.Null(candidate);
}
[Fact]
public async Task MarkRejected_WritesRejectedCountersLogAndBlacklist()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", name: "Канал"));
DiscoveryCandidateDto? candidate = await service.MarkRejectedAsync(
"-1001", "отклонено вручную", CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal(DiscoveryCandidateStatuses.Rejected, candidate!.Status);
Assert.Equal(1, store.Tasks.Single().Rejected); // счётчик rejected (python L559)
DiscoveryLogDto reject = Assert.Single(store.Log);
Assert.Equal(DiscoveryLogEvents.Reject, reject.Event);
Assert.Equal("отклонено вручную", reject.Text);
DiscoveryBlacklistDto entry = Assert.Single(store.Blacklist); // mark_rejected → чёрный список (L561)
Assert.Equal("-1001", entry.DialogId);
Assert.Equal("Канал", entry.Name);
Assert.Equal("отклонено вручную", entry.Reason);
}
[Fact]
public async Task MarkRejected_AlreadyRejected_IsIdempotent()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Rejected));
DiscoveryCandidateDto? candidate = await service.MarkRejectedAsync(
"-1001", "снова", CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal(0, store.Tasks.Single().Rejected);
Assert.Empty(store.Log);
Assert.Empty(store.Blacklist);
}
[Fact]
public async Task MarkRejected_JoinedCandidate_Throws()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Joined));
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.MarkRejectedAsync("-1001", "причина", CancellationToken.None));
Assert.Equal(DiscoveryCandidatesService.RejectJoinedDetail, error.Message);
Assert.Empty(store.Log);
Assert.Empty(store.Blacklist);
}
[Fact]
public async Task MarkRejected_MissingCandidate_ReturnsNull()
{
(DiscoveryCandidatesService service, _) = Create();
DiscoveryCandidateDto? candidate = await service.MarkRejectedAsync(
"-1001", "причина", CancellationToken.None);
Assert.Null(candidate);
}
[Fact]
public async Task SetStatus_ToReview_WritesReviewLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
DiscoveryCandidateDto? candidate = await service.SetStatusAsync(
"-1001", DiscoveryCandidateStatuses.Review, CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal(DiscoveryCandidateStatuses.Review, candidate!.Status);
DiscoveryLogDto review = Assert.Single(store.Log);
Assert.Equal(DiscoveryLogEvents.Review, review.Event);
Assert.Equal("кандидат -1001 переведён в review", review.Text);
}
[Fact]
public async Task SetStatus_Joined_Throws()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.SetStatusAsync("-1001", DiscoveryCandidateStatuses.Joined, CancellationToken.None));
Assert.Equal(
string.Format(DiscoveryCandidatesService.TransitionNotAllowedFormat, DiscoveryCandidateStatuses.Joined),
error.Message);
}
[Fact]
public async Task Set_UpdatesEvaluationFieldsAndKeepsEmptyName()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", name: "Канал"));
DiscoveryCandidateDto? candidate = await service.SetAsync(
"dt_1",
"-1001",
new DiscoveryCandidatePatch
{
Name = " ", // пустое после Trim — имя не затирается (python L480482)
Kind = DiscoveryCandidateKinds.Forum,
Participants = 1200,
LangRu = true,
Marks = new[] { "закрытая группа (история скрыта)" },
FitRatio = 0.62,
Topics = new[] { new DiscoveryTopicDto("topic_1", "Тема", FitCount: 4, Total: 5, FitRatio: 0.8, Passed: true) },
},
CancellationToken.None);
Assert.NotNull(candidate);
Assert.Equal("Канал", candidate!.Name); // пустое имя не применилось
Assert.Equal(DiscoveryCandidateKinds.Forum, candidate.Kind);
Assert.Equal(1200, candidate.Participants);
Assert.True(candidate.LangRu);
Assert.Equal(new[] { "закрытая группа (история скрыта)" }, candidate.Marks);
Assert.Equal(0.62, candidate.FitRatio);
DiscoveryTopicDto topic = Assert.Single(candidate.Topics);
Assert.Equal("topic_1", topic.TopicId);
Assert.True(topic.Passed);
}
[Fact]
public async Task Set_WrongTask_ReturnsNull()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedTask(Task("dt_2"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
DiscoveryCandidateDto? candidate = await service.SetAsync(
"dt_2", "-1001", new DiscoveryCandidatePatch { Participants = 10 }, CancellationToken.None);
Assert.Null(candidate); // кандидат другой задачи (python L465466)
}
[Fact]
public async Task Delete_RemovesCandidateRow()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
store.SeedCandidate(Candidate("-1001", "dt_1"));
await service.DeleteAsync("-1001", CancellationToken.None);
Assert.Empty(store.Candidates);
}
// Создаёт связку кандидатного сервиса + фейк (лог/чёрный список — те же фейки).
private static (DiscoveryCandidatesService Service, FakeDiscoveryStore Store) Create()
{
var store = new FakeDiscoveryStore();
var log = new DiscoveryLogService(store);
var blacklist = new DiscoveryBlacklistService(store);
return (new DiscoveryCandidatesService(store, log, blacklist), store);
}
// Задача как строка DiscTasks (running; счётчики 0).
private static DiscoveryTaskDto Task(string id, string status = DiscoveryTaskStatuses.Running)
{
return new DiscoveryTaskDto(
id,
"Задача",
string.Empty,
new[] { "ключ" },
MinSubscribers: 0,
Lang: "ru",
Threshold: 40,
SampleSize: 10,
PlanJoins: 50,
AutoJoin: true,
status,
SearchIdx: 0,
SearchDone: false,
Found: 0,
Evaluated: 0,
Joined: 0,
Rejected: 0,
CreatedAt: 1000,
UpdatedAt: 1000);
}
// Кандидат как строка DiscCandidates (адаптер-форма; переопределяемые поля).
private static DiscoveryCandidateDto Candidate(
string dialogId,
string taskId,
string? name = null,
string status = DiscoveryCandidateStatuses.New)
{
return new DiscoveryCandidateDto(
dialogId,
taskId,
name ?? dialogId,
string.Empty,
DiscoveryCandidateKinds.Channel,
"#666",
Participants: null,
LangRu: null,
new List<string>(),
new List<DiscoveryTopicDto>(),
FitRatio: null,
status,
AutoJoined: false,
JoinFailures: 0,
CreatedAt: 1000,
UpdatedAt: 1000);
}
}
@@ -0,0 +1,41 @@
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// Тесты DiscoveryLangDetector — доля кириллицы в выборке.
/// </summary>
public sealed class DiscoveryLangDetectorTests
{
[Fact]
public void DetectRu_AllRussian_True()
{
Assert.True(DiscoveryLangDetector.DetectRu(["Ищем заказ на разработку CRM для агентств", "Привет всем!"]));
}
[Fact]
public void DetectRu_LatinOnly_False()
{
Assert.False(DiscoveryLangDetector.DetectRu(["Hello, we are looking for a developer", "Please contact us"]));
}
[Fact]
public void DetectRu_EmptyOrNoLetters_Null()
{
Assert.Null(DiscoveryLangDetector.DetectRu([]));
Assert.Null(DiscoveryLangDetector.DetectRu([" ", "123 456", null]));
}
[Fact]
public void DetectRu_MixedBetweenThresholds_Null()
{
// В основном латинский текст с парой кириллических букв: доля между 0.03 и 0.15 — «не подтверждён».
Assert.Null(DiscoveryLangDetector.DetectRu(["developerscrmorderagencymarketingprojectforclients да", "need developers срочно please"]));
}
[Fact]
public void DetectRu_CyrillicAboveHighThreshold_True()
{
Assert.True(DiscoveryLangDetector.DetectRu(["Разработка CRM", "Нужен заказ", "Клиенты и агентства"]));
}
}
@@ -0,0 +1,70 @@
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// Тесты DiscoveryLogService — запись событий и чтение истории.
/// </summary>
public sealed class DiscoveryLogServiceTests
{
[Fact]
public async Task Add_WritesRowWithDlPrefix()
{
var store = new FakeDiscoveryStore();
var service = new DiscoveryLogService(store);
await service.AddAsync("dt_1", DiscoveryLogEvents.Skip, "пропущен -1001: причина", CancellationToken.None);
DiscoveryLogDto entry = Assert.Single(store.Log);
Assert.StartsWith("dl_", entry.Id); // id генерирует модуль (dl_)
Assert.Equal("dt_1", entry.TaskId);
Assert.Equal(DiscoveryLogEvents.Skip, entry.Event);
Assert.Equal("пропущен -1001: причина", entry.Text);
Assert.True(entry.CreatedAt > 0);
}
[Fact]
public async Task TaskLog_ReturnsNewestFirst()
{
long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryLogService(store);
await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "первое", CancellationToken.None); // t=1000
await service.AddAsync("dt_1", DiscoveryLogEvents.JoinAuto, "второе", CancellationToken.None); // t=2000
await service.AddAsync("dt_2", DiscoveryLogEvents.Done, "другая задача", CancellationToken.None); // t=3000
IReadOnlyList<DiscoveryLogDto> items = await service.TaskLogAsync("dt_1", CancellationToken.None);
Assert.Equal(2, items.Count); // события только dt_1
Assert.Equal(new[] { "второе", "первое" }, items.Select(entry => entry.Text)); // новые сверху (L605)
}
[Fact]
public async Task TaskLog_EmptyTask_ReturnsEmpty()
{
var store = new FakeDiscoveryStore();
var service = new DiscoveryLogService(store);
IReadOnlyList<DiscoveryLogDto> items = await service.TaskLogAsync("dt_no_log", CancellationToken.None);
Assert.Empty(items);
}
[Fact]
public async Task TaskLog_LimitTakesNewest()
{
long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryLogService(store);
await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "первое", CancellationToken.None);
await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "второе", CancellationToken.None);
await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "третье", CancellationToken.None);
IReadOnlyList<DiscoveryLogDto> items = await service.TaskLogAsync("dt_1", limit: 2, CancellationToken.None);
Assert.Equal(new[] { "третье", "второе" }, items.Select(entry => entry.Text));
}
}
@@ -0,0 +1,73 @@
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// Тесты <see cref="DiscoverySearchErrorCounter"/> — инкремент/сброс ошибок ключа поиска и TTL-эвикция.
/// </summary>
public sealed class DiscoverySearchErrorCounterTests
{
[Fact]
public void Next_IncrementsPerTask_ResetClears()
{
IDiscoverySearchErrorCounter counter = new DiscoverySearchErrorCounter(() => DateTimeOffset.UtcNow);
Assert.Equal(1, counter.Next("dt_1"));
Assert.Equal(2, counter.Next("dt_1"));
Assert.Equal(1, counter.Next("dt_2")); // ключи задач не пересекаются
counter.Reset("dt_1");
Assert.Equal(1, counter.Next("dt_1")); // после сброса отсчёт заново
Assert.Equal(2, counter.Next("dt_2")); // чужая запись не тронута
}
[Fact]
public void Next_ExpiredEntry_EvictsAndStartsFromOne()
{
var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
IDiscoverySearchErrorCounter counter = new DiscoverySearchErrorCounter(() => now);
Assert.Equal(1, counter.Next("dt_1"));
Assert.Equal(2, counter.Next("dt_1"));
// Запись старше EntryTtlSeconds: следующий Next начинает серию заново (эвикция просроченного).
now = now.AddSeconds(DiscoverySearchErrorCounter.EntryTtlSeconds + 1);
Assert.Equal(1, counter.Next("dt_1"));
}
[Fact]
public void Next_FreshEntry_NotEvictedAfterTtlReset()
{
var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
IDiscoverySearchErrorCounter counter = new DiscoverySearchErrorCounter(() => now);
Assert.Equal(1, counter.Next("dt_1"));
// Активная серия (запись обновлена): сдвиг в пределах TTL не сбрасывает счётчик.
now = now.AddSeconds(DiscoverySearchErrorCounter.EntryTtlSeconds / 2);
Assert.Equal(2, counter.Next("dt_1"));
now = now.AddSeconds(DiscoverySearchErrorCounter.EntryTtlSeconds - 1);
Assert.Equal(3, counter.Next("dt_1"));
}
[Fact]
public void Reset_EvictsExpiredOfOtherTasks()
{
var now = new DateTimeOffset(2026, 9, 9, 12, 0, 0, TimeSpan.Zero);
IDiscoverySearchErrorCounter counter = new DiscoverySearchErrorCounter(() => now);
counter.Next("dt_1"); // устареет
counter.Next("dt_2"); // свежая
now = now.AddSeconds(DiscoverySearchErrorCounter.EntryTtlSeconds + 1);
counter.Reset("dt_2"); // Reset триггерит эвикцию: dt_1 удаляется вместе с просроченными
Assert.Equal(1, counter.Next("dt_1")); // был эвиктирован — отсчёт заново
Assert.Equal(1, counter.Next("dt_2")); // Reset самой dt_2 тоже сбросил
}
}
@@ -0,0 +1,403 @@
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// Тесты DiscoveryTasksService — создание/патч/удаление/start/pause и план-бюджет.
/// </summary>
public sealed class DiscoveryTasksServiceTests
{
[Fact]
public async Task Create_EmptyName_ThrowsAndCreatesNothing()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(new DiscoveryTaskDraft { Name = " " }, CancellationToken.None));
Assert.Equal(DiscoveryTasksService.NameRequiredDetail, error.Message);
Assert.Empty(store.Tasks);
}
[Fact]
public async Task Create_PlanBelowOne_Throws()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(NewDraft(planJoins: 0), CancellationToken.None));
Assert.Equal(DiscoveryPlanGuard.PlanTooSmallDetail, error.Message);
Assert.Empty(store.Tasks);
}
[Fact]
public async Task Create_PlanAboveDailyLimit_Throws()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(NewDraft(planJoins: 51), CancellationToken.None));
Assert.Equal(string.Format(DiscoveryPlanGuard.PlanTooBigFormat, 51, 50), error.Message);
Assert.Empty(store.Tasks);
}
[Fact]
public async Task Create_TaskAtLimitLeavesNoBudget_Throws()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", planJoins: 50));
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(NewDraft(planJoins: 1), CancellationToken.None));
Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 50, 50, 1), error.Message);
Assert.Single(store.Tasks);
}
[Fact]
public async Task Create_RemainingQuota_FillsUpToLimitAndRejectsOver()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", planJoins: 25));
// 25 + 25 = 50 — вторая задача на 25 влезает (сумма планов ≤ discJoinLimit).
DiscoveryTaskDto second = await service.CreateAsync(NewDraft(planJoins: 25), CancellationToken.None);
Assert.Equal(25, second.PlanJoins);
// Остатка нет: третья задача (даже на 1) не создаётся.
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(NewDraft(planJoins: 1), CancellationToken.None));
Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 50, 50, 1), error.Message);
Assert.Equal(2, store.Tasks.Count);
}
[Fact]
public async Task Create_NormalizesAndCreatesDraft()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
DiscoveryTaskDto task = await service.CreateAsync(
new DiscoveryTaskDraft
{
Name = " Ищем клиентов ",
Description = "Ниша: CRM для агентств",
Keywords = new[] { " crm ", "", " агентство " },
MinSubscribers = -10,
Lang = "fr",
Threshold = 0,
AutoJoin = true,
},
CancellationToken.None);
Assert.StartsWith("dt_", task.Id);
Assert.Equal("Ищем клиентов", task.Name); // name — Trim
Assert.Equal(DiscoveryTaskStatuses.Draft, task.Status);
Assert.Equal(new[] { "crm", "агентство" }, task.Keywords); // без пустых, Trim
Assert.Equal(0, task.MinSubscribers); // кламп ≥0
Assert.Equal("ru", task.Lang); // не ru/any → ru
Assert.Equal(1, task.Threshold); // кламп 1..100
Assert.Equal(10, task.SampleSize); // дефолт discEvalSample
Assert.Equal(1, task.PlanJoins); // дефолт плана
Assert.True(task.AutoJoin);
Assert.Equal(0, task.SearchIdx);
Assert.False(task.SearchDone);
Assert.Equal(0, task.Found);
Assert.Equal(0, task.Evaluated);
Assert.Equal(0, task.Joined);
Assert.Equal(0, task.Rejected);
Assert.Single(store.Tasks);
}
[Fact]
public async Task Create_CustomThresholdAndSample_Respected()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, FakeSettingsStore settings) = Create();
settings.Preload("discEvalThreshold", "60");
settings.Preload("discEvalSample", "20");
DiscoveryTaskDto task = await service.CreateAsync(
new DiscoveryTaskDraft { Name = "Задача", Threshold = 80, SampleSize = 15 },
CancellationToken.None);
Assert.Equal(80, task.Threshold);
Assert.Equal(15, task.SampleSize);
Assert.Single(store.Tasks);
}
[Fact]
public async Task Patch_MissingTask_ReturnsNull()
{
(DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? patched = await service.PatchAsync(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None);
Assert.Null(patched);
}
[Fact]
public async Task Patch_AppliesFieldsAndClamps()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", keywords: new[] { "старое" }));
DiscoveryTaskDto? task = await service.PatchAsync(
"dt_1",
new DiscoveryTaskPatch
{
Name = " Новое имя ",
Threshold = 150,
MinSubscribers = -3,
Lang = "xx",
Keywords = new[] { " ", "ключ" },
},
CancellationToken.None);
Assert.NotNull(task);
Assert.Equal("Новое имя", task!.Name); // Trim
Assert.Equal(100, task.Threshold); // кламп 1..100
Assert.Equal(0, task.MinSubscribers); // кламп ≥0
Assert.Equal("ru", task.Lang); // не ru/any → ru
Assert.Equal(new[] { "ключ" }, task.Keywords); // пустые выброшены
Assert.True(task.UpdatedAt > 0); // bump UpdatedAt
}
[Fact]
public async Task Patch_NullOnlyPatch_ReturnsCurrentWithoutWrite()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", name: "Старое"));
DiscoveryTaskDto? task = await service.PatchAsync("dt_1", new DiscoveryTaskPatch(), CancellationToken.None);
Assert.NotNull(task);
Assert.Equal("Старое", task!.Name);
Assert.Single(store.Tasks);
}
[Fact]
public async Task Patch_PlanGrowthBeyondBudget_Throws()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", planJoins: 20));
store.SeedTask(Task("dt_2", planJoins: 20));
// Рост плана dt_1 до 30: занято другими 20 + 30 = 50 — влезает.
DiscoveryTaskDto? task = await service.PatchAsync(
"dt_1", new DiscoveryTaskPatch { PlanJoins = 30 }, CancellationToken.None);
Assert.NotNull(task);
Assert.Equal(30, task!.PlanJoins);
// Рост до 31: 20 + 31 = 51 > 50 — бюджет исчерпан (исключая саму задачу).
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.PatchAsync("dt_1", new DiscoveryTaskPatch { PlanJoins = 31 }, CancellationToken.None));
Assert.Equal(string.Format(DiscoveryPlanGuard.BudgetExceededFormat, 20, 50, 31), error.Message);
}
[Fact]
public async Task Start_MissingTask_ReturnsNull()
{
(DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None);
Assert.Null(task);
}
[Fact]
public async Task Start_NoKeywords_Throws()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", keywords: Array.Empty<string>()));
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.StartAsync("dt_1", CancellationToken.None));
Assert.Equal(DiscoveryTasksService.NoKeywordsDetail, error.Message);
}
[Fact]
public async Task Start_FromPaused_KeepsSearchProgress()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Paused, keywords: new[] { "a", "b" }, searchIdx: 1));
DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None);
Assert.NotNull(task);
Assert.Equal(DiscoveryTaskStatuses.Running, task!.Status);
Assert.Equal(1, task.SearchIdx); // продолжение из paused — прогресс сохраняется (python L342344)
Assert.False(task.SearchDone);
}
[Fact]
public async Task Start_FromDone_ResetsSearchProgress()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task(
"dt_1",
status: DiscoveryTaskStatuses.Done,
keywords: new[] { "a", "b" },
searchIdx: 2,
searchDone: true,
found: 7,
joined: 50));
DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None);
Assert.NotNull(task);
Assert.Equal(DiscoveryTaskStatuses.Running, task!.Status);
Assert.Equal(0, task.SearchIdx); // повторный прогон завершённой — свежий проход (python L332339)
Assert.False(task.SearchDone);
Assert.Equal(0, task.Found);
Assert.Equal(0, task.Joined);
}
[Fact]
public async Task Pause_SetsPaused()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Running, keywords: new[] { "a" }));
DiscoveryTaskDto? task = await service.PauseAsync("dt_1", CancellationToken.None);
Assert.NotNull(task);
Assert.Equal(DiscoveryTaskStatuses.Paused, task!.Status);
}
[Fact]
public async Task Delete_RemovesTaskCandidatesAndLog_KeepsBlacklist()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1"));
store.SeedTask(Task("dt_2"));
store.SeedCandidate(Candidate("c_1", "dt_1"));
store.SeedCandidate(Candidate("c_2", "dt_2"));
await store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None);
bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None);
Assert.True(deleted);
Assert.Single(store.Tasks); // dt_2 осталась
Assert.Equal("dt_2", Assert.Single(store.Tasks).Id);
Assert.Single(store.Candidates); // кандидат dt_2 остался
Assert.Equal("c_2", Assert.Single(store.Candidates).DialogId);
Assert.Single(store.Blacklist); // чёрный список общий — не удаляется
}
[Fact]
public async Task Delete_MissingTask_ReturnsFalse()
{
(DiscoveryTasksService service, _, _) = Create();
bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None);
Assert.False(deleted);
}
[Fact]
public async Task AdvanceSearch_MovesIndexAndFinishes()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", keywords: new[] { "a", "b" }));
Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None));
DiscoveryTaskDto? afterFirst = store.Tasks.Single();
Assert.Equal(1, afterFirst.SearchIdx);
Assert.False(afterFirst.SearchDone);
Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None));
DiscoveryTaskDto afterLast = store.Tasks.Single();
Assert.Equal(2, afterLast.SearchIdx);
Assert.True(afterLast.SearchDone); // индекс за последним ключом — проход закрыт (python L376377)
}
[Fact]
public async Task BumpCounter_IncrementsField()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1"));
Assert.True(await service.BumpCounterAsync("dt_1", DiscoveryCounterField.Evaluated, 3, CancellationToken.None));
Assert.Equal(3, store.Tasks.Single().Evaluated);
Assert.False(await service.BumpCounterAsync("dt_missing", DiscoveryCounterField.Found, 1, CancellationToken.None));
}
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
private static (DiscoveryTasksService Service, FakeDiscoveryStore Store, FakeSettingsStore Settings) Create()
{
var store = new FakeDiscoveryStore();
var settings = new FakeSettingsStore();
var planGuard = new DiscoveryPlanGuard(store, settings);
return (new DiscoveryTasksService(store, planGuard, settings), store, settings);
}
// Draft задачи с дефолтными полями (name/planJoins переопределяются).
private static DiscoveryTaskDraft NewDraft(string name = "Задача", int? planJoins = null)
{
return new DiscoveryTaskDraft { Name = name, PlanJoins = planJoins };
}
// Задача как строка DiscTasks (адаптер-форма; неиспользуемые поля — нейтральные значения).
private static DiscoveryTaskDto Task(
string id,
string name = "Задача",
string status = DiscoveryTaskStatuses.Running,
string[]? keywords = null,
int planJoins = 5,
int searchIdx = 0,
bool searchDone = false,
int found = 0,
int evaluated = 0,
int joined = 0,
int rejected = 0)
{
return new DiscoveryTaskDto(
id,
name,
string.Empty,
keywords ?? new[] { "ключ" },
MinSubscribers: 0,
Lang: "ru",
Threshold: 40,
SampleSize: 10,
planJoins,
AutoJoin: true,
status,
searchIdx,
searchDone,
found,
evaluated,
joined,
rejected,
CreatedAt: 1000,
UpdatedAt: 1000);
}
// Кандидат как строка DiscCandidates (адаптер-форма).
private static DiscoveryCandidateDto Candidate(string dialogId, string taskId)
{
return new DiscoveryCandidateDto(
dialogId,
taskId,
dialogId,
string.Empty,
DiscoveryCandidateKinds.Channel,
"#666",
Participants: null,
LangRu: null,
new List<string>(),
new List<DiscoveryTopicDto>(),
FitRatio: null,
DiscoveryCandidateStatuses.New,
AutoJoined: false,
JoinFailures: 0,
CreatedAt: 1000,
UpdatedAt: 1000);
}
}
@@ -0,0 +1,27 @@
using Deal.Modules.Discovery.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// Фейковый <see cref="IDiscoveryPacer"/> для тестов воркера Discovery
/// </summary>
public sealed class FakeDiscoveryPacer : IDiscoveryPacer
{
/// <summary>
/// Действие, выполняемое в момент паузы
/// </summary>
public Action? OnDelay { get; set; }
/// <summary>
/// Сколько раз запрошена пауза перед авто-вступлением
/// </summary>
public int DelayCalls { get; private set; }
/// <inheritdoc />
public Task WaitJoinDelayAsync(CancellationToken ct)
{
DelayCalls++;
OnDelay?.Invoke();
return Task.CompletedTask;
}
}
@@ -0,0 +1,614 @@
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models;
namespace Deal.Tests.Unit.Modules.Discovery;
/// <summary>
/// In-memory реализация <see cref="IDiscoveryStore"/> для unit-тестов Discovery.
/// </summary>
public sealed class FakeDiscoveryStore : IDiscoveryStore
{
private readonly List<DiscoveryTaskDto> _tasks = [];
private readonly List<DiscoveryCandidateDto> _candidates = [];
private readonly List<DiscoveryBlacklistDto> _blacklist = [];
private readonly List<DiscoveryLogDto> _log = [];
private readonly HashSet<string> _monitoredIds = new(StringComparer.Ordinal);
private readonly Func<DateTimeOffset> _clock;
/// <summary>
/// Создаёт фейк с «часами» по умолчанию
/// </summary>
/// <param name="clock">Источник текущего времени (по умолчанию <see cref="DateTimeOffset.UtcNow"/>).</param>
public FakeDiscoveryStore(Func<DateTimeOffset>? clock = null)
{
_clock = clock ?? (() => DateTimeOffset.UtcNow);
}
/// <summary>
/// Задачи фейка
/// </summary>
public IReadOnlyList<DiscoveryTaskDto> Tasks => _tasks.ToList();
/// <summary>
/// Кандидаты фейка
/// </summary>
public IReadOnlyList<DiscoveryCandidateDto> Candidates => _candidates.ToList();
/// <summary>
/// Чёрный список фейка
/// </summary>
public IReadOnlyList<DiscoveryBlacklistDto> Blacklist => _blacklist.ToList();
/// <summary>
/// Лог фейка в порядке записи
/// </summary>
public IReadOnlyList<DiscoveryLogDto> Log => _log.ToList();
/// <summary>
/// Кладёт задачу напрямую
/// </summary>
/// <param name="task">Задача как если бы была прочитана адаптером.</param>
public void SeedTask(DiscoveryTaskDto task)
{
_tasks.Add(task);
}
/// <summary>
/// Кладёт кандидата напрямую
/// </summary>
/// <param name="candidate">Кандидат как если бы был прочитан адаптером.</param>
public void SeedCandidate(DiscoveryCandidateDto candidate)
{
_candidates.Add(candidate);
}
/// <summary>
/// Помечает источник «уже мониторится» — есть в каталоге Dialogs.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
public void SeedMonitored(string dialogId)
{
_monitoredIds.Add(dialogId);
}
/// <summary>
/// Кладёт событие лога напрямую
/// </summary>
/// <param name="event">Событие (join_auto/…).</param>
/// <param name="createdAt">Момент события (UTC).</param>
/// <param name="taskId">Id задачи (по умолчанию — произвольный).</param>
public void SeedLog(
string @event,
DateTimeOffset createdAt,
string taskId = "dt_x")
{
_log.Add(new DiscoveryLogDto("dl_x", taskId, @event, string.Empty, createdAt.ToUnixTimeMilliseconds()));
}
/// <inheritdoc />
public Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<DiscoveryTaskDto>>(_tasks
.OrderBy(task => task.CreatedAt)
.ToList());
}
/// <inheritdoc />
public Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
{
return Task.FromResult(_tasks.FirstOrDefault(task => task.Id == taskId));
}
/// <inheritdoc />
public Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
{
long nowMs = _clock().ToUnixTimeMilliseconds();
_tasks.Add(new DiscoveryTaskDto(
row.Id,
row.Name,
row.Description,
row.Keywords.ToList(),
row.MinSubscribers,
row.Lang,
row.Threshold,
row.SampleSize,
row.PlanJoins,
row.AutoJoin,
DiscoveryTaskStatuses.Draft,
SearchIdx: 0,
SearchDone: false,
Found: 0,
Evaluated: 0,
Joined: 0,
Rejected: 0,
nowMs,
nowMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> PatchTaskAsync(
string taskId,
DiscoveryTaskPatch patch,
CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryTaskDto task = _tasks[index];
if (patch.Name is not null)
{
task = task with { Name = patch.Name };
}
if (patch.Description is not null)
{
task = task with { Description = patch.Description };
}
if (patch.Keywords is not null)
{
task = task with { Keywords = patch.Keywords.ToList() };
}
if (patch.MinSubscribers is int minSubscribers)
{
task = task with { MinSubscribers = minSubscribers };
}
if (patch.Lang is not null)
{
task = task with { Lang = patch.Lang };
}
if (patch.Threshold is int threshold)
{
task = task with { Threshold = threshold };
}
if (patch.SampleSize is int sampleSize)
{
task = task with { SampleSize = sampleSize };
}
if (patch.PlanJoins is int planJoins)
{
task = task with { PlanJoins = planJoins };
}
if (patch.AutoJoin is bool autoJoin)
{
task = task with { AutoJoin = autoJoin };
}
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
{
if (!_tasks.Any(task => task.Id == taskId))
{
return false;
}
_tasks.RemoveAll(task => task.Id == taskId);
_candidates.RemoveAll(candidate => candidate.TaskId == taskId);
_log.RemoveAll(entry => entry.TaskId == taskId);
return true;
}
/// <inheritdoc />
public Task<bool> SetTaskRunningAsync(
string taskId,
bool resetProgress,
CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryTaskDto task = _tasks[index] with { Status = DiscoveryTaskStatuses.Running };
if (resetProgress)
{
task = task with
{
SearchIdx = 0,
SearchDone = false,
Found = 0,
Evaluated = 0,
Joined = 0,
Rejected = 0,
};
}
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
Status = DiscoveryTaskStatuses.Paused,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
Status = DiscoveryTaskStatuses.Done,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> BumpTaskCounterAsync(
string taskId,
DiscoveryCounterField field,
int n,
CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0 || n <= 0)
{
return Task.FromResult(index >= 0);
}
DiscoveryTaskDto task = field switch
{
DiscoveryCounterField.Found => _tasks[index] with { Found = _tasks[index].Found + n },
DiscoveryCounterField.Evaluated => _tasks[index] with { Evaluated = _tasks[index].Evaluated + n },
DiscoveryCounterField.Joined => _tasks[index] with { Joined = _tasks[index].Joined + n },
DiscoveryCounterField.Rejected => _tasks[index] with { Rejected = _tasks[index].Rejected + n },
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи"),
};
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> AdvanceSearchAsync(
string taskId,
int nextIndex,
bool searchDone,
CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
SearchIdx = nextIndex,
SearchDone = searchDone,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{
IEnumerable<DiscoveryTaskDto> active = _tasks
.Where(task => !DiscoveryTaskStatuses.IsFinished(task.Status));
if (excludeTaskId is not null)
{
active = active.Where(task => task.Id != excludeTaskId);
}
return Task.FromResult(active.Sum(task => task.PlanJoins));
}
/// <inheritdoc />
public Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync(
string taskId,
string? status,
CancellationToken ct)
{
IEnumerable<DiscoveryCandidateDto> query = _candidates.Where(candidate => candidate.TaskId == taskId);
if (status is not null)
{
query = query.Where(candidate => candidate.Status == status);
}
return Task.FromResult<IReadOnlyList<DiscoveryCandidateDto>>(query
.OrderBy(candidate => candidate.CreatedAt)
.ToList());
}
/// <inheritdoc />
public Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_candidates.FirstOrDefault(candidate => candidate.DialogId == dialogId));
}
/// <inheritdoc />
public Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_monitoredIds.Contains(dialogId));
}
/// <inheritdoc />
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_blacklist.Any(entry => entry.DialogId == dialogId));
}
/// <inheritdoc />
public Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
{
long nowMs = _clock().ToUnixTimeMilliseconds();
_candidates.Add(new DiscoveryCandidateDto(
row.DialogId,
row.TaskId,
row.Name,
row.Username,
row.Kind,
row.Hue,
Participants: null,
LangRu: null,
new List<string>(),
new List<DiscoveryTopicDto>(),
FitRatio: null,
DiscoveryCandidateStatuses.New,
AutoJoined: false,
JoinFailures: 0,
nowMs,
nowMs));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
{
_candidates.RemoveAll(candidate => candidate.DialogId == dialogId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> PatchCandidateAsync(
string dialogId,
DiscoveryCandidatePatch patch,
CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryCandidateDto candidate = _candidates[index];
if (patch.Name is not null)
{
candidate = candidate with { Name = patch.Name };
}
if (patch.Username is not null)
{
candidate = candidate with { Username = patch.Username };
}
if (patch.Kind is not null)
{
candidate = candidate with { Kind = patch.Kind };
}
if (patch.Hue is not null)
{
candidate = candidate with { Hue = patch.Hue };
}
if (patch.Participants is int participants)
{
candidate = candidate with { Participants = participants };
}
if (patch.LangRu is bool langRu)
{
candidate = candidate with { LangRu = langRu };
}
if (patch.Marks is not null)
{
candidate = candidate with { Marks = patch.Marks.ToList() };
}
if (patch.Topics is not null)
{
candidate = candidate with { Topics = patch.Topics.ToList() };
}
if (patch.FitRatio is double fitRatio)
{
candidate = candidate with { FitRatio = fitRatio };
}
if (patch.AutoJoined is bool autoJoined)
{
candidate = candidate with { AutoJoined = autoJoined };
}
_candidates[index] = candidate with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> SetCandidateStatusAsync(
string dialogId,
string status,
CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = status,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> SetCandidateJoinedAsync(
string dialogId,
bool autoJoined,
CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = DiscoveryCandidateStatuses.Joined,
AutoJoined = autoJoined,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0 || _candidates[index].Status != DiscoveryCandidateStatuses.Review)
{
return Task.FromResult<int?>(null);
}
DiscoveryCandidateDto candidate = _candidates[index] with
{
JoinFailures = _candidates[index].JoinFailures + 1,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
_candidates[index] = candidate;
return Task.FromResult<int?>(candidate.JoinFailures);
}
/// <inheritdoc />
public Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = DiscoveryCandidateStatuses.Rejected,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
/// <inheritdoc />
public Task UpsertBlacklistAsync(
string dialogId,
string name,
string reason,
CancellationToken ct)
{
DiscoveryBlacklistDto? existing = _blacklist.FirstOrDefault(entry => entry.DialogId == dialogId);
if (existing is null)
{
_blacklist.Add(new DiscoveryBlacklistDto(dialogId, name, reason, _clock().ToUnixTimeMilliseconds()));
}
else
{
_blacklist[_blacklist.IndexOf(existing)] = existing with { Name = name, Reason = reason };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
{
_blacklist.RemoveAll(entry => entry.DialogId == dialogId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_blacklist.FirstOrDefault(entry => entry.DialogId == dialogId));
}
/// <inheritdoc />
public Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<DiscoveryBlacklistDto>>(_blacklist
.OrderByDescending(entry => entry.CreatedAt)
.ToList());
}
/// <inheritdoc />
public Task AddLogAsync(
string logId,
string taskId,
string logEvent,
string text,
CancellationToken ct)
{
_log.Add(new DiscoveryLogDto(logId, taskId, logEvent, text, _clock().ToUnixTimeMilliseconds()));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> CountLogEventAsync(
string logEvent,
DateTimeOffset sinceUtc,
CancellationToken ct)
{
long sinceMs = sinceUtc.ToUnixTimeMilliseconds();
return Task.FromResult(_log.Count(entry => entry.Event == logEvent && entry.CreatedAt >= sinceMs));
}
/// <inheritdoc />
public Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync(
string taskId,
int limit,
CancellationToken ct)
{
IReadOnlyList<DiscoveryLogDto> entries = _log
.Where(entry => entry.TaskId == taskId)
.OrderByDescending(entry => entry.CreatedAt)
.Take(limit)
.ToList();
return Task.FromResult(entries);
}
}
@@ -0,0 +1,181 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты контекст-билдера ИИ-классификации <see cref="AiClassifyContextBuilder"/>.
/// </summary>
public sealed class AiClassifyContextBuilderTests
{
private const string Domain = "IT-разработка";
private static readonly string[] Keywords = { "бот", "сайт" };
private static string Json(object value) => System.Text.Json.JsonSerializer.Serialize(value);
// ─── Промпты: fill_prompt ({domain}/{keywords}) ─────────────────────────
[Fact]
public async Task BuildFilterPrompt_FillsDomainAndKeywords()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
var builder = new AiClassifyContextBuilder(settings, kanj);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
Assert.Equal("Страж. Сфера: IT-разработка. Маркеры: бот, сайт.", prompt);
}
[Fact]
public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}"));
var builder = new AiClassifyContextBuilder(settings, kanj);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
Assert.Equal(
$"[{PromptFiller.FallbackDomain}] {PromptFiller.NoKeywordsHint}",
prompt);
}
[Fact]
public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
settings.Preload(SettingsKeys.AiPrompt, Json("Классифицируй {domain}."));
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
var builder = new AiClassifyContextBuilder(settings, kanj);
string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None);
Assert.Equal("Классифицируй IT-разработка.\n\nВерни блок «О заявке» бот, сайт.", prompt);
}
// ─── User-контекст: доски + примеры + сообщение ─────────────────────────
[Fact]
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj);
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
Assert.Contains("- (колонок пока нет — верните board: null)", context);
Assert.DoesNotContain("Примеры разметки", context);
Assert.EndsWith("Новое сообщение:\nНужен разработчик", context);
}
[Fact]
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
kanj.SeedBoard(new ContainerDto
{
Id = "b_py",
Name = "Python-разработка",
Description = "Бэкенд и боты на Python",
Rules = new ContainerRulesDto(
Mode: "any",
Direction: Array.Empty<string>(),
Keywords: new[] { "python", "django", "fastapi" },
Stack: Array.Empty<string>(),
Grade: Array.Empty<string>(),
Exclude: Array.Empty<string>(),
Budget: null),
Order = 0,
});
kanj.SeedBoard(new ContainerDto
{
Id = "b_site",
Name = "Сайты",
Rules = new ContainerRulesDto(
Mode: "all",
Direction: new[] { "веб-разработка" },
Keywords: Array.Empty<string>(),
Stack: Array.Empty<string>(),
Grade: Array.Empty<string>(),
Exclude: Array.Empty<string>(),
Budget: null),
Order = 1,
});
kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 });
var builder = new AiClassifyContextBuilder(settings, kanj);
string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None);
// Колонка с правилами — критерии RulesDescriber (слова/направление) и описание.
Assert.Contains("b_py: Python-разработка", context);
Assert.Contains("слова: python, django, fastapi", context);
Assert.Contains("— Бэкенд и боты на Python", context);
Assert.Contains("- b_site: Сайты (критерии: все условия · направление: веб-разработка)", context);
Assert.DoesNotContain("b_sug", context);
}
[Fact]
public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)");
SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)");
var builder = new AiClassifyContextBuilder(settings, kanj);
string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None);
Assert.Contains(
"Примеры разметки пользователя:\nтекст: Пример: senior go (свежий)\n→ колонка: b_py\n"
+ "текст: Пример: middle python (старый)\n→ колонка: b_py",
context);
}
[Fact]
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj);
string text = new string('д', 6000);
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
Assert.EndsWith("Новое сообщение:\n" + new string('д', 5000), context);
}
// ─── Хелперы ────────────────────────────────────────────────────────────
// Создаёт контекст: пустое KV + пустой канбан-фейк.
// kanj: Канбан-фейк (доски/журнал примеров).
// Возвращает: KV-хранилище тенанта.
private static FakeSettingsStore Context(out FakeKanjStore kanj)
{
kanj = new FakeKanjStore();
return new FakeSettingsStore();
}
// Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера).
// kanj: Канбан-фейк.
// cardId: Id карточки (l_…).
// board: Колонка-назначение (доска).
// text: Исходный текст карточки (source_msg).
private static void SeedCardWithMove(
FakeKanjStore kanj,
string cardId,
string board,
string text)
{
kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = text } });
kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult();
}
}
@@ -0,0 +1,135 @@
using Deal.Modules.Kanban.Application.ColumnRules;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты парсера сумм — AmountParser.
/// </summary>
public sealed class AmountParserTests
{
// Парсит текст и возвращает единственную сумму (тест-хелпер).
// text: Текст с суммой.
// Возвращает: Единственный AmountRange.
private static AmountRange Single(string text)
{
IReadOnlyList<AmountRange> amounts = AmountParser.Parse(text);
return Assert.Single(amounts);
}
// ─── Словесные конструкции «от…до» / «до…» / «от…» ─────────────────────
[Fact]
public void Parse_WordRangeFromTo_ReturnsFromAndToWithCurrency()
{
Assert.Equal(new AmountRange(1000, 2000, "USD"), Single("Бюджет от 1000 до 2000 $"));
}
[Fact]
public void Parse_ToOnly_ReturnsUpperBoundOnly()
{
Assert.Equal(new AmountRange(From: null, To: 5000, Cur: "RUB"), Single("Зарплата до 5000 ₽"));
}
[Fact]
public void Parse_FromOnly_ReturnsSingleAmount()
{
Assert.Equal(new AmountRange(1000, 1000, "EUR"), Single("Ставка от 1000 €"));
}
[Fact]
public void Parse_WordRangeWithKSuffix_MultipliesByThousand()
{
Assert.Equal(new AmountRange(1500, 3000, "USD"), Single("ЗП от 1.5к до 3к $"));
}
// ─── Числовые диапазоны «A–B» ──────────────────────────────────────────
[Fact]
public void Parse_NumericRangeWithSpaceThousands_ReturnsBounds()
{
Assert.Equal(new AmountRange(1200, 1500, "USD"), Single("Бюджет 1 2001 500 $"));
}
[Fact]
public void Parse_NumericRangeWithCurrencyWordAfter_ReturnsBounds()
{
Assert.Equal(new AmountRange(1500, 2500, "RUB"), Single("Бюджет 15002500 рублей"));
}
[Fact]
public void Parse_DollarSymbolBeforeRange_IsNotDetected()
{
Assert.Empty(AmountParser.Parse("Вилка $50100"));
}
// ─── Суффикс «к/К» и одиночные суммы ───────────────────────────────────
[Fact]
public void Parse_SingleAmountWithKSuffixAndSymbol_ReturnsThousand()
{
Assert.Equal(new AmountRange(2000, 2000, "USD"), Single("Оплата 2к$"));
}
[Fact]
public void Parse_SingleAmountWithCurrencyWord_ReturnsValue()
{
Assert.Equal(new AmountRange(5000, 5000, "USD"), Single("Оплата 5 000 долларов"));
}
[Fact]
public void Parse_SingleAmountSymbolAfterNumber_ReturnsCurrency()
{
Assert.Equal(new AmountRange(1200, 1200, "USD"), Single("Фикс 1200$"));
}
// ─── Валюты: символы, слова, особенности ───────────────────────────────
[Fact]
public void Parse_WordUsdtAfterNumber_IsParsedAsUsd()
{
Assert.Equal(new AmountRange(2000, 2000, "USD"), Single("Бюджет 2000 usdt"));
}
[Fact]
public void Parse_SymbolTugrik_MapsToUsdt()
{
Assert.Equal(new AmountRange(2000, 2000, "USDT"), Single("Бюджет 2000 ₮"));
}
[Fact]
public void Parse_SeveralAmounts_DifferentCurrencies_ReturnsBoth()
{
IReadOnlyList<AmountRange> amounts = AmountParser.Parse("1500 € и отдельно 2000 $");
Assert.Equal(2, amounts.Count);
Assert.Contains(new AmountRange(1500, 1500, "EUR"), amounts);
Assert.Contains(new AmountRange(2000, 2000, "USD"), amounts);
}
[Fact]
public void Parse_WordRangeAndSingleAmount_NoDuplicates()
{
IReadOnlyList<AmountRange> amounts = AmountParser.Parse("от 1000 до 2000 $ и ещё 3000 €");
Assert.Equal(2, amounts.Count);
Assert.Contains(new AmountRange(1000, 2000, "USD"), amounts);
Assert.Contains(new AmountRange(3000, 3000, "EUR"), amounts);
}
// ─── Без валюты / без суммы ────────────────────────────────────────────
[Fact]
public void Parse_AmountWithoutCurrency_ReturnsEmpty()
{
Assert.Empty(AmountParser.Parse("от 1000 до 2000"));
Assert.Empty(AmountParser.Parse("Зарплата 1000"));
}
[Fact]
public void Parse_NoText_ReturnsEmpty()
{
Assert.Empty(AmountParser.Parse(null));
Assert.Empty(AmountParser.Parse(string.Empty));
Assert.Empty(AmountParser.Parse(" "));
}
}
@@ -0,0 +1,184 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты нормализации бюджета — BudgetNormalizer.
/// </summary>
public sealed class BudgetNormalizerTests
{
private static readonly IReadOnlyDictionary<string, double> MockRates = new Dictionary<string, double>
{
["RUB"] = 1.0,
["USD"] = 92.5,
["EUR"] = 99.9,
["USDT"] = 92.5,
};
[Fact]
public void Normalize_SingleSum_SetsToEqualsFrom()
{
CardBudgetDto? result = BudgetNormalizer.Normalize(new BudgetRangeDto(From: 150000, To: null, Cur: "RUB"));
Assert.Equal(new CardBudgetDto(150000, 150000, "RUB"), result);
}
[Fact]
public void Normalize_ToOnly_KeepsFromNull()
{
CardBudgetDto? result = BudgetNormalizer.Normalize(new BudgetRangeDto(From: null, To: 5000, Cur: "USD"));
Assert.Equal(new CardBudgetDto(From: null, To: 5000, Cur: "USD"), result);
}
[Fact]
public void Normalize_Range_KeepsBounds()
{
CardBudgetDto? result = BudgetNormalizer.Normalize(new BudgetRangeDto(From: 1000, To: 2000, Cur: "USD"));
Assert.Equal(new CardBudgetDto(1000, 2000, "USD"), result);
}
[Fact]
public void Normalize_FromZero_TreatsAsNoLowerBound()
{
CardBudgetDto? result = BudgetNormalizer.Normalize(new BudgetRangeDto(From: 0, To: 100, Cur: "RUB"));
Assert.Equal(new CardBudgetDto(From: null, To: 100, Cur: "RUB"), result);
}
[Fact]
public void Normalize_BothBoundsZero_ReturnsNull()
{
Assert.Null(BudgetNormalizer.Normalize(new BudgetRangeDto(From: 0, To: 0, Cur: "USD")));
}
[Fact]
public void Normalize_NoBounds_ReturnsNull()
{
Assert.Null(BudgetNormalizer.Normalize(new BudgetRangeDto(From: null, To: null, Cur: "USD")));
}
[Fact]
public void Normalize_NullBudget_ReturnsNull()
{
Assert.Null(BudgetNormalizer.Normalize(null));
}
[Fact]
public void Normalize_UnknownCurrency_ReturnsNull()
{
Assert.Null(BudgetNormalizer.Normalize(new BudgetRangeDto(From: 100, To: 200, Cur: "₴")));
}
[Fact]
public void Normalize_CurrencyAliases_MapToCodes()
{
Assert.Equal(new CardBudgetDto(100, 100, "RUB"), BudgetNormalizer.Normalize(new BudgetRangeDto(From: 100, To: null, Cur: "рублей")));
Assert.Equal(new CardBudgetDto(100, 100, "USD"), BudgetNormalizer.Normalize(new BudgetRangeDto(From: 100, To: null, Cur: "$")));
Assert.Equal(new CardBudgetDto(100, 100, "RUB"), BudgetNormalizer.Normalize(new BudgetRangeDto(From: 100, To: null, Cur: "₽")));
}
[Fact]
public void Normalize_ThreeLetterCode_AcceptedAsIs()
{
CardBudgetDto? result = BudgetNormalizer.Normalize(new BudgetRangeDto(From: 100, To: null, Cur: "eur"));
Assert.Equal(new CardBudgetDto(100, 100, "EUR"), result);
}
[Fact]
public void ToTarget_ConversionDisabled_ReturnsNull()
{
Assert.Null(BudgetNormalizer.ToTarget(
new CardBudgetDto(1000, 2000, "USD"),
conversionOn: false,
targetCurrency: "RUB",
MockRates));
}
[Fact]
public void ToTarget_NoBudget_ReturnsNull()
{
Assert.Null(BudgetNormalizer.ToTarget(null, conversionOn: true, targetCurrency: "RUB", MockRates));
Assert.Null(BudgetNormalizer.ToTarget(new CardBudgetDto(1000, 2000, string.Empty), conversionOn: true, targetCurrency: "RUB", MockRates));
}
[Fact]
public void ToTarget_UsdBudgetToRub_ConvertsBothBounds()
{
CardBudgetDto? result = BudgetNormalizer.ToTarget(
new CardBudgetDto(1000, 2000, "USD"),
conversionOn: true,
targetCurrency: "RUB",
MockRates);
Assert.Equal(new CardBudgetDto(92500, 185000, "RUB"), result);
}
[Fact]
public void ToTarget_SingleBound_ConvertsAndCopiesToFrom()
{
CardBudgetDto? result = BudgetNormalizer.ToTarget(
new CardBudgetDto(From: 2000, To: null, Cur: "USD"),
conversionOn: true,
targetCurrency: "RUB",
MockRates);
Assert.Equal(new CardBudgetDto(185000, 185000, "RUB"), result);
}
[Fact]
public void ToTarget_UsdtBudget_UsesUsdRate()
{
var ratesWithoutUsdt = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 92.5 };
CardBudgetDto? result = BudgetNormalizer.ToTarget(
new CardBudgetDto(1000, 1500, "USDT"),
conversionOn: true,
targetCurrency: "RUB",
ratesWithoutUsdt);
Assert.Equal(new CardBudgetDto(92500, 138750, "RUB"), result);
}
[Fact]
public void ToTarget_CurrencyMissingInRates_KeepsTargetCurrencyWithNullBounds()
{
CardBudgetDto? result = BudgetNormalizer.ToTarget(
new CardBudgetDto(1000, 2000, "KZT"),
conversionOn: true,
targetCurrency: "RUB",
MockRates);
Assert.Equal(new CardBudgetDto(From: null, To: null, Cur: "RUB"), result);
}
[Fact]
public void ToTarget_EmptyTargetCurrency_DefaultsToRub()
{
CardBudgetDto? result = BudgetNormalizer.ToTarget(
new CardBudgetDto(1000, null, "USD"),
conversionOn: true,
targetCurrency: string.Empty,
MockRates);
Assert.Equal(new CardBudgetDto(92500, 92500, "RUB"), result);
}
[Fact]
public void ToTarget_NullRates_ReturnsNullBoundsWithTargetCurrency()
{
CardBudgetDto? result = BudgetNormalizer.ToTarget(
new CardBudgetDto(1000, 2000, "USD"),
conversionOn: true,
targetCurrency: "RUB",
rates: null);
// Чистая функция: без курсов границы не конвертируются (целевая валюта сохраняется).
Assert.Equal(new CardBudgetDto(From: null, To: null, Cur: "RUB"), result);
}
}
@@ -0,0 +1,219 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты напоминаний «Отложено» — <see cref="CardsService"/>
/// </summary>
public sealed class CardsServiceRemindersTests
{
private const long DayMs = 86_400_000;
[Fact]
public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот"));
CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None);
Assert.Equal(CardsService.RemindersDisabledDetail, result.Error); // 400 у эндпоинта
Assert.Null(result.Card);
Assert.Null(Assert.Single(store.CardDtos).Reminder); // запись не выполнена
}
[Fact]
public async Task Set_Enabled_SetsReminderAndReturnsCard()
{
(CardsService service, FakeKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1));
long atMs = NowMs() + DayMs;
CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None);
Assert.Null(result.Error);
CardDto card = result.Card!;
Assert.Equal("c_1", card.Id);
Assert.Equal(atMs, card.Reminder!.At); // наружу reminder={at}, epoch-ms
CardDto stored = Assert.Single(store.CardDtos);
Assert.Equal(atMs, stored.Reminder!.At);
Assert.True(stored.UpdatedAtMs > 1); // set_reminder бампит updated_at
}
[Fact]
public async Task Set_MissingCard_Returns404EvenWhenDisabled()
{
(CardsService service, _, _) = Create(remindersEnabled: false);
CardResultDto result = await service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None);
Assert.Null(result.Error);
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
}
[Fact]
public async Task Set_StageNotHold_Allowed()
{
(CardsService service, FakeKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "work", title: "В работе"));
long atMs = NowMs() + DayMs;
CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None);
Assert.Null(result.Error);
Assert.Equal("work", result.Card!.Col);
Assert.Equal(atMs, result.Card!.Reminder!.At);
}
[Fact]
public async Task Clear_WithReminder_ClearsItAndReturnsTrue()
{
(CardsService service, FakeKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
Assert.True(cleared);
Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен
}
[Fact]
public async Task Clear_MissingCard_ReturnsFalse()
{
(CardsService service, _, _) = Create();
bool cleared = await service.ClearReminderAsync("c_missing", CancellationToken.None);
Assert.False(cleared); // эндпоинт отвечает 404 «Карточка не найдена»
}
[Fact]
public async Task Clear_RemindersDisabled_StillClears()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
Assert.True(cleared);
Assert.Null(Assert.Single(store.CardDtos).Reminder);
}
[Fact]
public async Task Snooze_MovesReminderToNowPlus24h()
{
(CardsService service, FakeKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
long beforeMs = NowMs();
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
Assert.True(snoozed);
long afterMs = NowMs();
CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!;
Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч
}
[Fact]
public async Task Snooze_MissingCard_ReturnsFalse()
{
(CardsService service, _, _) = Create();
bool snoozed = await service.SnoozeReminderAsync("c_missing", CancellationToken.None);
Assert.False(snoozed); // эндпоинт отвечает 404 «Карточка не найдена»
}
[Fact]
public async Task Snooze_RemindersDisabled_StillSnoozes()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
Assert.True(snoozed);
Assert.NotNull(Assert.Single(store.CardDtos).Reminder);
}
[Fact]
public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
long pastMs = NowMs() - 1;
store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) });
store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) });
store.SeedCard(Card("c_future", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
IReadOnlyList<CardReminderDueDto> due = await service.CheckDueRemindersAsync(CancellationToken.None);
Assert.Empty(due); // выключено → событий нет
// Протухшие очищены БЕЗ учёта stage/fired, будущее не тронуто.
Assert.Null(store.CardDtos.Single(card => card.Id == "c_hold_past").Reminder);
Assert.Null(store.CardDtos.Single(card => card.Id == "c_work_past").Reminder);
Assert.NotNull(store.CardDtos.Single(card => card.Id == "c_future").Reminder);
}
[Fact]
public async Task CheckDue_Enabled_ReturnsDueAndMarksFired()
{
(CardsService service, FakeKanjStore store, _) = Create();
store.SeedCard(Card("c_d1", stage: "hold", title: "Ранний") with { Reminder = new CardReminderDto(NowMs() - 2 * DayMs) });
store.SeedCard(Card("c_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) });
store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
store.SeedCard(Card("c_work", stage: "work", title: "В работе") with { Reminder = new CardReminderDto(NowMs() - 1) });
IReadOnlyList<CardReminderDueDto> due = await service.CheckDueRemindersAsync(CancellationToken.None);
// Только hold-карточки с наступившим и не сработавшим напоминанием, ORDER BY reminder_at.
Assert.Equal(
new[] { ("c_d1", "Ранний"), ("c_d2", "Поздний") },
due.Select(item => (item.Id, item.Title)));
Assert.All(due, item => Assert.Equal("hold", item.ContainerId));
// «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД).
Assert.Empty(await store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
}
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true)
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
if (!remindersEnabled)
{
settings.Preload(SettingsKeys.RemindersEnabled, "false");
}
return (new CardsService(store, settings, new FakeMlClient(), new FakeFileStorage()), store, settings);
}
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1).
private static CardDto Card(
string id,
string stage = "planned",
string title = "",
long updatedAtMs = 1)
{
return new CardDto
{
Id = id,
Col = stage,
Title = title,
CreatedAtMs = 1,
UpdatedAtMs = updatedAtMs,
};
}
}
@@ -0,0 +1,376 @@
using Deal.Modules.Kanban.Application.ColumnRules;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты чистых правил колонок — ColumnRules.
/// </summary>
public sealed class ColumnRulesTests
{
private static readonly IReadOnlyDictionary<string, double> MockRates = new Dictionary<string, double>
{
["RUB"] = 1.0,
["USD"] = 92.5,
["EUR"] = 99.9,
["USDT"] = 92.5,
};
// ─── Конструктор правил ────────────────────────────────────────────────
// Собирает ContainerRulesDto с непустыми группами из параметров (пустые — отключены).
// mode: Режим all/any.
// direction: Термы «направление».
// keywords: Термы «слова».
// stack: Термы «стек».
// grade: Термы «грейд».
// exclude: Термы-исключения.
// budget: Бюджетный диапазон.
// Возвращает: Правила колонки.
private static ContainerRulesDto Rules(
string mode = "all",
string[]? direction = null,
string[]? keywords = null,
string[]? stack = null,
string[]? grade = null,
string[]? exclude = null,
BudgetRangeDto? budget = null)
{
return new ContainerRulesDto(
mode,
direction ?? Array.Empty<string>(),
keywords ?? Array.Empty<string>(),
stack ?? Array.Empty<string>(),
grade ?? Array.Empty<string>(),
exclude ?? Array.Empty<string>(),
budget);
}
[Fact]
public void Matches_KeywordsModeAll_SingleGroupHit_ReturnsTrue()
{
ContainerRulesDto rules = Rules(keywords: new[] { "WPF" });
Assert.True(ColumnRules.Matches(rules, "Ищем WPF-разработчика для десктопа"));
}
[Fact]
public void Matches_ModeAll_SecondEnabledGroupMissing_ReturnsFalse()
{
ContainerRulesDto rules = Rules(keywords: new[] { "WPF" }, stack: new[] { "react" });
// all: обе включённые группы обязаны совпасть — «react» в тексте нет.
Assert.False(ColumnRules.Matches(rules, "Ищем WPF-разработчика для десктопа"));
}
[Fact]
public void Matches_ModeAll_AllGroupsPresent_ReturnsTrue()
{
ContainerRulesDto rules = Rules(keywords: new[] { "WPF" }, stack: new[] { "react" });
Assert.True(ColumnRules.Matches(rules, "Ищем WPF-разработчика со знанием React"));
}
[Fact]
public void Matches_ModeAny_OneOfTwoGroupsHit_ReturnsTrue()
{
ContainerRulesDto rules = Rules(mode: "any", keywords: new[] { "WPF" }, stack: new[] { "react" });
// any: достаточно одной группы — react в тексте есть, WPF нет.
Assert.True(ColumnRules.Matches(rules, "Ищем React-разработчика"));
}
[Fact]
public void Matches_ModeAny_NoGroupHit_ReturnsFalse()
{
ContainerRulesDto rules = Rules(mode: "any", keywords: new[] { "WPF" }, stack: new[] { "react" });
Assert.False(ColumnRules.Matches(rules, "Ищем дизайнера в команду"));
}
[Fact]
public void Matches_Keywords_CaseInsensitive_ReturnsTrue()
{
ContainerRulesDto rules = Rules(keywords: new[] { "wpf" });
// Регистр термина и текста не важен (оба приводятся к нижнему).
Assert.True(ColumnRules.Matches(rules, "Нужен WPF разработчик"));
}
[Fact]
public void Matches_KeywordInsensitiveRussian_CaseInsensitive_ReturnsTrue()
{
ContainerRulesDto rules = Rules(keywords: new[] { "телеграм" });
Assert.True(ColumnRules.Matches(rules, "Сделаю бота для Телеграм-канала"));
}
[Fact]
public void Matches_EmptyRules_ReturnsFalse()
{
Assert.False(ColumnRules.Matches(Rules(), "Любой текст"));
}
[Fact]
public void Matches_UrlTailWord_DoesNotHit()
{
ContainerRulesDto rules = Rules(keywords: new[] { "desktop" });
// «desktop» живёт только в utm-хвосте ссылки — после чистки ссылок совпадения нет.
Assert.False(ColumnRules.Matches(rules, "Смотрите https://t.me/c/123?utm_medium=member_desktop"));
}
[Fact]
public void Matches_MarkdownLinkUrl_DoesNotHit()
{
ContainerRulesDto rules = Rules(keywords: new[] { "example" });
Assert.False(ColumnRules.Matches(rules, "Подробнее [тут](https://example.com/vacancy/42)"));
}
[Fact]
public void ExcludedTerms_TermPresent_ReturnsIt()
{
ContainerRulesDto rules = Rules(exclude: new[] { "крипта" });
IReadOnlyList<string> terms = ColumnExclusions.ExcludedTerms(rules, "Продвижение в крипта-каналах");
Assert.Equal(new[] { "крипта" }, terms);
}
[Fact]
public void BoardAccepts_ExcludeVeto_ReturnsFalseEvenWhenPositiveMatched()
{
ContainerRulesDto rules = Rules(keywords: new[] { "бот" }, exclude: new[] { "курсы" });
// Положительные критерии совпали, но слово-исключение — вето: в колонку не кладём.
Assert.True(ColumnRules.Matches(rules, "Сделаю бота, но это не курсы"));
Assert.False(ColumnRules.ContainerAccepts(rules, "Сделаю бота, но это не курсы"));
}
[Fact]
public void BoardAccepts_NoRules_AcceptsAnyText()
{
Assert.True(ColumnRules.ContainerAccepts(null, "Любой текст"));
Assert.True(ColumnRules.ContainerAccepts(Rules(), "Любой текст"));
}
[Fact]
public void BoardAccepts_ActiveRules_TextNotMatched_ReturnsFalse()
{
ContainerRulesDto rules = Rules(keywords: new[] { "WPF" });
Assert.True(ColumnRules.ContainerAccepts(rules, "Ищем WPF-разработчика"));
Assert.False(ColumnRules.ContainerAccepts(rules, "Ищем дизайнера"));
}
[Fact]
public void BoardAccepts_ActiveRulesWithoutExclude_TextMatched_ReturnsTrue()
{
ContainerRulesDto rules = Rules(keywords: new[] { "бот" });
Assert.True(ColumnRules.ContainerAccepts(rules, "Нужен бот для телеграма"));
}
[Fact]
public void HasActiveRules_EmptyRules_ReturnsFalse()
{
Assert.False(ColumnRules.HasActiveRules(null));
Assert.False(ColumnRules.HasActiveRules(Rules()));
}
[Fact]
public void HasActiveRules_BudgetWithBound_ReturnsTrue()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 1000, To: 2000, Cur: "USD"));
Assert.True(ColumnRules.HasActiveRules(rules));
}
[Fact]
public void HasActiveRules_BudgetWithoutBounds_ReturnsFalse()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: null, To: null, Cur: "USD"));
Assert.False(ColumnRules.HasActiveRules(rules));
}
[Fact]
public void Matches_GradeAliasMid_TextWithMid_ReturnsTrue()
{
ContainerRulesDto rules = Rules(grade: new[] { "middle" });
Assert.True(ColumnRules.Matches(rules, "Нужен mid python разработчик"));
Assert.True(ColumnRules.Matches(rules, "Нужен мидл python разработчик"));
}
[Fact]
public void Matches_GradeAliasSenior_TextRussian_ReturnsTrue()
{
ContainerRulesDto rules = Rules(grade: new[] { "senior" });
Assert.True(ColumnRules.Matches(rules, "Ищем сеньора C# в команду"));
}
[Fact]
public void Matches_GradeAliasLead_TeamLead_ReturnsTrue()
{
ContainerRulesDto rules = Rules(grade: new[] { "lead" });
Assert.True(ColumnRules.Matches(rules, "Нужен team lead для команды ботов"));
}
// ─── Бюджет: бюджетная группа правил (budget_диапазон, конвертация) ───
[Fact]
public void Matches_Budget_SameCurrencyAmountInRange_ReturnsTrue()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 1000, To: 2000, Cur: "USD"));
Assert.True(ColumnRules.Matches(rules, "Зарплата от 1500 до 1800 $"));
}
[Fact]
public void Matches_Budget_AmountBelowLowerBound_ReturnsFalse()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 5000, To: 9000, Cur: "USD"));
Assert.False(ColumnRules.Matches(rules, "Бюджет 1000$"));
}
[Fact]
public void Matches_Budget_OtherCurrencyConvertedWithRates_ReturnsTrue()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 90000, To: 190000, Cur: "RUB"));
// $10002000 по курсу 92.5 = 92 500185 000 ₽ — в диапазоне.
Assert.True(ColumnRules.Matches(rules, "Бюджет 10002000 $", MockRates));
}
[Fact]
public void Matches_Budget_OtherCurrencyWithoutRates_ReturnsFalse()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 90000, To: 190000, Cur: "RUB"));
// Курсов нет — конвертировать нечем, суммы «не в диапазоне» (чистая функция без хранилища).
Assert.False(ColumnRules.Matches(rules, "Бюджет 10002000 $"));
}
[Fact]
public void Matches_Budget_UsdtTreatedAsUsd_ReturnsTrue()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 1000, To: 2000, Cur: "USD"));
Assert.True(ColumnRules.Matches(rules, "Бюджет 10002000 ₮", MockRates));
}
[Fact]
public void ComputeHits_NoActiveRules_ReturnsEmpty()
{
Assert.Empty(ColumnRules.ComputeHits(null, "Любой текст"));
Assert.Empty(ColumnRules.ComputeHits(Rules(), "Любой текст"));
Assert.Empty(ColumnRules.ComputeHits(Rules(budget: new BudgetRangeDto(From: null, To: null, Cur: "USD")), "Любой текст"));
}
[Fact]
public void ComputeHits_MatchedGroups_ReturnsHitsWithLabelsTermsAndWord()
{
ContainerRulesDto rules = Rules(
direction: new[] { "React" },
keywords: new[] { "бот" },
stack: new[] { "WPF" },
grade: new[] { "Middle" });
string text = "Нужен React разработчик со знанием WPF и бота на C#, грейд middle";
IReadOnlyList<MatchHitDto> hits = ColumnRules.ComputeHits(rules, text);
Assert.Equal(4, hits.Count);
Assert.Equal(new MatchHitDto("Направление", "React", Word: null), hits[0]);
Assert.Equal(new MatchHitDto("Слова", "бот", Word: null), hits[1]);
Assert.Equal(new MatchHitDto("Стек", "WPF", Word: null), hits[2]);
// term — тег как сохранён, word — фактически найденный синоним.
Assert.Equal(new MatchHitDto("Грейд/уровень", "Middle", "middle"), hits[3]);
}
[Fact]
public void ComputeHits_GradeAliasWord_PicksFirstFoundSynonym()
{
ContainerRulesDto rules = Rules(grade: new[] { "middle" });
IReadOnlyList<MatchHitDto> hits = ColumnRules.ComputeHits(rules, "Нужен mid python разработчик");
MatchHitDto hit = Assert.Single(hits);
Assert.Equal("Грейд/уровень", hit.Label);
Assert.Equal("middle", hit.Term);
Assert.Equal("mid", hit.Word);
}
[Fact]
public void ComputeHits_BudgetHit_AddsHumanReadableRangeTerm()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 1000, To: 2000, Cur: "USD"));
IReadOnlyList<MatchHitDto> hits = ColumnRules.ComputeHits(rules, "Зарплата 1500$");
MatchHitDto hit = Assert.Single(hits);
Assert.Equal("Бюджет", hit.Label);
Assert.Equal("от 1000 до 2000 USD", hit.Term);
}
[Fact]
public void ComputeHits_BudgetOutsideRange_ReturnsEmpty()
{
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 5000, To: 9000, Cur: "USD"));
Assert.Empty(ColumnRules.ComputeHits(rules, "Зарплата 1000$"));
}
[Fact]
public void Describe_NoRules_ReturnsDefaultText()
{
Assert.Equal("без правил (решает ИИ/ML)", ColumnRules.Describe(null));
Assert.Equal("без правил (решает ИИ/ML)", ColumnRules.Describe(Rules()));
}
[Fact]
public void Describe_Groups_JoinsPartsWithModeAllPrefix()
{
ContainerRulesDto rules = Rules(
direction: new[] { "Python", "FastAPI" },
stack: new[] { "Postgres" },
keywords: new[] { "бот" },
grade: new[] { "middle" });
Assert.Equal(
"все условия · направление: Python, FastAPI; стек: Postgres; слова: бот; грейд: middle",
ColumnRules.Describe(rules));
}
[Fact]
public void Describe_ModeAny_ChangesPrefix()
{
ContainerRulesDto rules = Rules(mode: "any", keywords: new[] { "бот" });
Assert.Equal("любое из условий · слова: бот", ColumnRules.Describe(rules));
}
[Fact]
public void Describe_BudgetAndExclude_AppendParts()
{
ContainerRulesDto rules = Rules(
keywords: new[] { "бот" },
exclude: new[] { "курсы" },
budget: new BudgetRangeDto(From: 1000, To: 2000, Cur: "USD"));
Assert.Equal(
"все условия · слова: бот; исключено: курсы; бюджет: 10002000 USD",
ColumnRules.Describe(rules));
}
}
@@ -0,0 +1,79 @@
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// In-memory реализация <see cref="IMlLearningStore"/> для unit-тестов ML-интеграции.
/// </summary>
public sealed class FakeMlLearningStore : IMlLearningStore
{
private readonly List<(string Id, string Text, string Label, double Delta)> _rows = [];
/// <summary>
/// Число записей журнала обучения
/// </summary>
public int LearningCount { get; set; }
/// <summary>
/// Строки очереди
/// </summary>
public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList();
/// <inheritdoc />
public Task<int> CountLearningAsync(CancellationToken ct) => Task.FromResult(LearningCount);
/// <inheritdoc />
public Task<int> CountOutboxAsync(CancellationToken ct) => Task.FromResult(_rows.Count);
/// <inheritdoc />
public Task AddOutboxAsync(
string id,
string text,
string label,
double delta,
CancellationToken ct)
{
_rows.Add((id, text, label, delta));
return Task.CompletedTask;
}
/// <summary>
/// Пишет строку очереди напрямую
/// </summary>
/// <param name="id">Id строки (<c>mle_...</c>).</param>
/// <param name="text">Текст обучающего примера.</param>
/// <param name="label">Метка обучения.</param>
/// <param name="delta">Вес сигнала.</param>
public void SeedOutbox(
string id,
string text,
string label,
double delta = 1.0)
{
_rows.Add((id, text, label, delta));
}
/// <inheritdoc />
public Task ClearOutboxAsync(CancellationToken ct)
{
_rows.Clear();
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<MlOutboxEntryDto>> TakeOutboxBatchAsync(int limit, CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<MlOutboxEntryDto>>(
_rows.Take(limit)
.Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta))
.ToList());
}
/// <inheritdoc />
public Task DeleteOutboxAsync(IReadOnlyCollection<string> ids, CancellationToken ct)
{
_rows.RemoveAll(row => ids.Contains(row.Id));
return Task.CompletedTask;
}
}
@@ -0,0 +1,315 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// In-memory реализация <see cref="IPipelineStore"/> для unit-тестов PipelineIngestService/PipelineProcessingService.
/// </summary>
public class FakePipelineStore : IPipelineStore
{
private readonly List<QueueItemDto> _queue = [];
private readonly List<RejectedItemDto> _rejected = [];
private readonly Dictionary<string, string?> _dedup = new(StringComparer.Ordinal);
/// <summary>
/// Строки очереди фейка
/// </summary>
public IReadOnlyList<QueueItemDto> Queue => _queue.ToList();
/// <summary>
/// Записи отсева фейка
/// </summary>
public IReadOnlyList<RejectedItemDto> Rejected => _rejected.ToList();
/// <summary>
/// Кладёт строку очереди напрямую
/// </summary>
/// <param name="item">Строка как если бы была сохранена в БД.</param>
public void SeedQueue(QueueItemDto item)
{
_queue.Add(item);
}
/// <summary>
/// Кладёт запись отсева напрямую
/// </summary>
/// <param name="item">Запись как если бы была сохранена в БД.</param>
public void SeedRejected(RejectedItemDto item)
{
_rejected.Add(item);
}
// ── Очередь (QueueItems) ───────────────────────────────────────────────
/// <inheritdoc />
public Task<bool> ExistsDuplicateAsync(
SourceRef source,
CancellationToken ct)
{
string key = source.DedupeKey();
bool found = _queue.Any(item => item.Source.DedupeKey() == key);
return Task.FromResult(found);
}
/// <inheritdoc />
public Task AddAsync(QueueItemDto item, CancellationToken ct)
{
_queue.Add(item);
return Task.CompletedTask;
}
/// <inheritdoc />
public virtual Task<IReadOnlyList<QueueItemDto>> ListAsync(
string? status,
int limit,
CancellationToken ct)
{
IReadOnlyList<QueueItemDto> items = _queue
.Where(item => status is null || item.Status == status)
.OrderBy(item => item.QueuedAtMs)
.Take(limit)
.ToList();
return Task.FromResult(items);
}
/// <inheritdoc />
public Task<int> CountByStatusAsync(string status, CancellationToken ct)
{
return Task.FromResult(_queue.Count(item => item.Status == status));
}
/// <inheritdoc />
public Task SetStatusAsync(
string id,
string status,
CancellationToken ct)
{
int index = _queue.FindIndex(item => item.Id == id);
if (index >= 0)
{
_queue[index] = _queue[index] with { Status = status };
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveAsync(string id, CancellationToken ct)
{
_queue.RemoveAll(item => item.Id == id);
return Task.CompletedTask;
}
// ── Отсев (RejectedItems) ──────────────────────────────────────────────
/// <inheritdoc />
public Task UpsertAsync(RejectRecord record, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(record.Text))
{
return Task.CompletedTask;
}
string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected);
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
int index = _rejected.FindIndex(item => item.Id == id);
RejectedItemDto row = ToItem(record, id, nowMs);
if (index >= 0)
{
RejectedItemDto existing = _rejected[index];
row = row with
{
Returned = existing.Returned,
ReturnedAtMs = existing.ReturnedAtMs,
ReturnReason = existing.ReturnReason,
};
_rejected[index] = row;
}
else
{
_rejected.Add(row);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<RejectedItemDto>> ListPageAsync(
int offset,
int limit,
CancellationToken ct)
{
IReadOnlyList<RejectedItemDto> page = _rejected
.OrderByDescending(item => item.RejectedAtMs)
.Skip(offset)
.Take(limit)
.ToList();
return Task.FromResult(page);
}
/// <inheritdoc />
public Task<IReadOnlyList<RejectedItemDto>> SearchAsync(
string q,
int limitFts,
int limitLike,
CancellationToken ct)
{
string query = q.Trim();
IReadOnlyList<RejectedItemDto> rows = _rejected
.Where(item => LikeField(item.Text, query)
|| LikeField(item.Reason, query)
|| LikeField(item.Kw, query)
|| LikeField(item.Source.DisplayName ?? string.Empty, query))
.OrderByDescending(item => item.RejectedAtMs)
.Take(limitLike)
.ToList();
return Task.FromResult(rows);
}
/// <inheritdoc />
public Task<int> CountAsync(CancellationToken ct)
{
return Task.FromResult(_rejected.Count);
}
/// <inheritdoc />
public Task<RejectedItemDto?> GetAsync(string id, CancellationToken ct)
{
return Task.FromResult(_rejected.FirstOrDefault(item => item.Id == id));
}
/// <inheritdoc />
public Task DeleteAsync(string id, CancellationToken ct)
{
_rejected.RemoveAll(item => item.Id == id);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearAsync(CancellationToken ct)
{
int cleared = _rejected.Count;
_rejected.Clear();
return Task.FromResult(cleared);
}
/// <inheritdoc />
public Task<int> PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct)
{
long cutMs = olderThan.ToUnixTimeMilliseconds();
int removed = _rejected.RemoveAll(item => item.RejectedAtMs < cutMs);
return Task.FromResult(removed);
}
/// <inheritdoc />
public Task MarkReturnedAsync(
string id,
string reason,
DateTimeOffset returnedAt,
CancellationToken ct)
{
int index = _rejected.FindIndex(item => item.Id == id);
if (index >= 0)
{
_rejected[index] = _rejected[index] with
{
Returned = true,
ReturnedAtMs = returnedAt.ToUnixTimeMilliseconds(),
ReturnReason = reason,
};
}
return Task.CompletedTask;
}
// ── Дедуп (DedupEntries) ───────────────────────────────────────────────
/// <summary>
/// Id карточки, связанной с хэшем дедупа
/// </summary>
/// <param name="hash">SHA1-hex нормализованного текста.</param>
public string? DedupLeadId(string hash)
{
return _dedup.TryGetValue(hash, out string? leadId) ? leadId : null;
}
/// <inheritdoc />
public virtual Task<bool> ExistsAsync(string hash, CancellationToken ct)
{
return Task.FromResult(_dedup.ContainsKey(hash));
}
/// <inheritdoc />
public virtual Task<bool> ClaimAsync(string hash, CancellationToken ct)
{
// Атомарность claim'а (как PipelineStore.ClaimAsync, ON CONFLICT DO NOTHING): true — заявка занята
// этим вызовом, false — хэш уже заявлен (TryAdd) другим проходом pump.
return Task.FromResult(_dedup.TryAdd(hash, null));
}
/// <inheritdoc />
public virtual Task DeleteClaimAsync(string hash, CancellationToken ct)
{
if (_dedup.TryGetValue(hash, out string? leadId) && leadId is null)
{
_dedup.Remove(hash);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task LinkAsync(
string hash,
string cardId,
CancellationToken ct)
{
_dedup[hash] = cardId;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteByCardAsync(string cardId, CancellationToken ct)
{
foreach (string hash in _dedup.Where(pair => pair.Value == cardId).Select(pair => pair.Key).ToList())
{
_dedup.Remove(hash);
}
return Task.CompletedTask;
}
// ── Маппинг (как адаптер: подписи — словари PipelineRejectConstants) ────
// Подстрока q в lower(поле) — LIKE-половина адаптера (поле колонки SQL приводится к нижнему регистру).
// field: Поле записи отсева (text/reason/kw/ch_name).
// query: Поисковый запрос, нормализованный сервисом (trim+lowercase).
// Возвращает: True — поле содержит запрос без учёта регистра поля.
private static bool LikeField(string field, string query)
{
return field.ToLowerInvariant().Contains(query, StringComparison.Ordinal);
}
private static RejectedItemDto ToItem(
RejectRecord record,
string id,
long rejectedAtMs) => new()
{
Id = id,
Source = record.Source,
Content = record.Content,
Text = record.Text,
Stage = record.Stage,
StageLabel = PipelineRejectConstants.StageLabel(record.Stage),
Reason = record.Reason,
Kw = record.Kw,
DecidedBy = record.DecidedBy,
DecidedByLabel = PipelineRejectConstants.SourceLabel(record.DecidedBy),
MsgAtMs = record.MsgAtMs,
RejectedAtMs = rejectedAtMs,
};
}
@@ -0,0 +1,99 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты FileKindDetector — категории вложений по MIME/расширению.
/// </summary>
public sealed class FileKindDetectorTests
{
[Fact]
public void Detect_ImageExtensions_ReturnsImageKind()
{
foreach (string extension in new[] { "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif", "heic" })
{
AssertDetected($"photo.{extension}", mime: null, kind: "image", label: "Изображение");
}
}
[Fact]
public void Detect_VideoExtensions_ReturnsVideoKind()
{
foreach (string extension in new[] { "mp4", "mov", "avi", "mkv", "webm", "m4v" })
{
AssertDetected($"clip.{extension}", mime: null, kind: "video", label: "Видео");
}
}
[Fact]
public void Detect_AudioExtensions_ReturnsAudioKind()
{
foreach (string extension in new[] { "mp3", "wav", "ogg", "m4a", "flac", "aac" })
{
AssertDetected($"record.{extension}", mime: null, kind: "audio", label: "Аудио");
}
}
[Fact]
public void Detect_ArchiveExtensions_ReturnsArchiveKind()
{
foreach (string extension in new[] { "zip", "rar", "7z", "tar", "gz", "bz2" })
{
AssertDetected($"bundle.{extension}", mime: null, kind: "archive", label: "Архив");
}
}
[Fact]
public void Detect_DocumentExtensions_ReturnsDocumentKind()
{
foreach (string extension in new[] { "pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "md", "rtf", "ppt", "pptx", "odt", "ods" })
{
AssertDetected($"file.{extension}", mime: null, kind: "document", label: "Документ");
}
}
[Fact]
public void Detect_ImageMimeOverridesUnknownExtension_ReturnsImageKind()
{
AssertDetected("photo.unknown", mime: "image/png", kind: "image", label: "Изображение");
}
[Fact]
public void Detect_VideoAndAudioMimePrefixes_MapToOwnKinds()
{
AssertDetected("payload.bin", mime: "video/mp4", kind: "video", label: "Видео");
AssertDetected("payload.bin", mime: "audio/mpeg", kind: "audio", label: "Аудио");
}
[Fact]
public void Detect_UnknownExtensionWithoutMime_ReturnsOtherWithFileLabel()
{
AssertDetected("archive.bin", mime: null, kind: "other", label: "Файл");
AssertDetected("no-extension", mime: "", kind: "other", label: "Файл");
AssertDetected("trailing-dot.", mime: null, kind: "other", label: "Файл");
}
[Fact]
public void Detect_MixedCaseExtensionAndMime_IsCaseInsensitive()
{
AssertDetected("PHOTO.PNG", mime: null, kind: "image", label: "Изображение");
AssertDetected("Clip.Mp4", mime: null, kind: "video", label: "Видео");
AssertDetected("file.unknown", mime: "IMAGE/PNG", kind: "image", label: "Изображение");
}
private static void AssertDetected(
string name,
string? mime,
string kind,
string label)
{
CardFileKind result = FileKindDetector.Detect(name, mime);
Assert.Equal(kind, result.Kind);
Assert.Equal(label, result.Label);
}
}
@@ -0,0 +1,21 @@
using Deal.Modules.Kanban;
using Deal.Modules.Pipeline;
namespace Deal.Tests.Unit.Modules.Kanban;
public sealed class MarkerTests
{
[Fact]
public void IPipelineModule_IsPublicInterface()
{
Assert.True(typeof(IPipelineModule).IsPublic);
Assert.True(typeof(IPipelineModule).IsInterface);
}
[Fact]
public void IKanbanModule_IsPublicInterface()
{
Assert.True(typeof(IKanbanModule).IsPublic);
Assert.True(typeof(IKanbanModule).IsInterface);
}
}
@@ -0,0 +1,253 @@
using System.Globalization;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты ручной проверки/разметки ML
/// </summary>
public sealed class MlReviewServiceTests
{
private const string Dialog = "d_1";
private static MlReviewService Create(
FakePipelineStore pipeline,
FakeKanjStore kanj,
FakeMlClient ml,
out CardsService cards)
{
var settings = new FakeSettingsStore();
cards = new CardsService(kanj, settings, ml, new FakeFileStorage());
var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline));
return new MlReviewService(pipeline, kanj, cards, processing, ml);
}
private static QueueItemDto QueueRow(
long msgId,
string text,
string dialog = Dialog) => SourceItemFactory.Queue(
$"p_{msgId}",
text,
PipelineQueueStatuses.New,
origin: dialog,
externalId: msgId.ToString(CultureInfo.InvariantCulture),
msgAtMs: 1_700_000_000_000 + msgId);
private static RejectedItemDto RejectedRow(
long msgId,
string text,
string dialog = Dialog) => SourceItemFactory.Rejected(
$"r_{msgId}",
text,
stage: "spam_ml",
decidedBy: "ml",
reason: "спам",
origin: dialog,
externalId: msgId.ToString(CultureInfo.InvariantCulture),
msgAtMs: 1_700_000_000_000 + msgId);
private static CardDto Card(
long msgId,
string text,
string col = "inbox",
string dialog = Dialog) => new()
{
Id = $"c_{msgId}",
Col = col,
Title = text,
Source = SourceItemFactory.Source(
origin: dialog,
externalId: msgId.ToString(CultureInfo.InvariantCulture)),
Content = SourceItemFactory.Content(text),
ReceivedAtMs = 1_700_000_000_000 + msgId,
};
[Fact]
public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts()
{
var pipeline = new FakePipelineStore();
pipeline.SeedQueue(QueueRow(101, "из очереди"));
pipeline.SeedRejected(RejectedRow(102, "из отсева"));
var kanj = new FakeKanjStore();
kanj.SeedCard(Card(103, "из карточки"));
MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
Assert.Equal(3, items.Count);
Assert.Contains(items, item => item.Source.ExternalId == "101" && item.Verdict == MlReviewService.VerdictQueued && !item.Lead);
Assert.Contains(items, item => item.Source.ExternalId == "102" && item.Verdict == MlReviewService.VerdictRejected);
MlCandidateDto cardItem = Assert.Single(items, item => item.Source.ExternalId == "103");
Assert.True(cardItem.Lead);
Assert.Equal(MlReviewService.VerdictCard, cardItem.Verdict);
Assert.Equal("inbox", cardItem.Col);
}
[Fact]
public async Task Candidates_FiltersByDialog()
{
var pipeline = new FakePipelineStore();
pipeline.SeedQueue(QueueRow(101, "нужный", Dialog));
pipeline.SeedQueue(QueueRow(201, "другой", "d_2"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
MlCandidateDto only = Assert.Single(items);
Assert.Equal("101", only.Source.ExternalId);
}
[Fact]
public async Task Candidates_NoDialog_ReturnsAllSources()
{
var pipeline = new FakePipelineStore();
pipeline.SeedQueue(QueueRow(101, "a", "d_1"));
pipeline.SeedQueue(QueueRow(201, "b", "d_2"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(null, 10, CancellationToken.None);
Assert.Equal(2, items.Count);
}
[Fact]
public async Task Candidates_ClampsLimitToMax()
{
var pipeline = new FakePipelineStore();
for (int i = 0; i < 5; i++)
{
pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}"));
}
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None);
Assert.Equal(2, items.Count);
}
[Fact]
public async Task Apply_Skip_DoesNotLearn()
{
var pipeline = new FakePipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст"));
var ml = new FakeMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None);
Assert.NotNull(result);
Assert.True(result!.Ok);
Assert.False(result.Learned);
Assert.Empty(ml.Pushed);
Assert.Single(pipeline.Queue);
}
[Fact]
public async Task Apply_Spam_WithCard_TrashesAndLearns()
{
var kanj = new FakeKanjStore();
kanj.SeedCard(Card(101, "спамный текст"));
var ml = new FakeMlClient();
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
Assert.NotNull(result);
Assert.True(result!.Learned);
Assert.Equal("trash", result.Moved);
Assert.Equal("c_101", result.LeadId);
Assert.Equal("trash", Assert.Single(kanj.CardDtos).Col);
Assert.Contains(("спамный текст", "spam", 1.0), ml.Pushed);
}
[Fact]
public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue()
{
var pipeline = new FakePipelineStore();
pipeline.SeedQueue(QueueRow(101, "рекламный текст"));
var ml = new FakeMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
Assert.NotNull(result);
Assert.True(result!.Learned);
Assert.Null(result.Moved);
Assert.Empty(pipeline.Queue);
RejectedItemDto rejected = Assert.Single(pipeline.Rejected);
Assert.Equal("spam_ml", rejected.Stage);
Assert.Equal("ml", rejected.DecidedBy);
Assert.Contains(("рекламный текст", "spam", 1.0), ml.Pushed);
}
[Fact]
public async Task Apply_Board_MovesCardAndLearns()
{
var kanj = new FakeKanjStore();
kanj.SeedBoard(new ContainerDto
{
Id = "b_py",
Name = "Python",
Space = ContainerSpaces.Dashboard,
Kind = ContainerKinds.Board,
});
kanj.SeedCard(Card(101, "python разработчик"));
var ml = new FakeMlClient();
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None);
Assert.NotNull(result);
Assert.True(result!.Learned);
Assert.Equal("b_py", result.Moved);
Assert.Equal("b_py", Assert.Single(kanj.CardDtos).Col);
Assert.Contains(("python разработчик", "b_py", 1.0), ml.Pushed);
}
[Fact]
public async Task Apply_UnknownBoard_ReturnsError()
{
var kanj = new FakeKanjStore();
kanj.SeedCard(Card(101, "текст"));
MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None);
Assert.NotNull(result);
Assert.False(result!.Ok);
Assert.Equal(MlReviewService.UnknownBoardDetail, result.Error);
}
[Fact]
public async Task Apply_UnknownAction_ReturnsError()
{
var pipeline = new FakePipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None);
Assert.NotNull(result);
Assert.False(result!.Ok);
Assert.Equal(MlReviewService.UnknownActionDetail, result.Error);
}
[Fact]
public async Task Apply_MessageNotFound_ReturnsNull()
{
MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None);
Assert.Null(result);
}
}
@@ -0,0 +1,227 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты StorageTickService — тик правил хранения.
/// </summary>
public sealed class StorageTickServiceTests
{
[Fact]
public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16))));
store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(20))));
store.SeedCard(Card("l_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(2, stats.Archived);
Assert.Equal(0, stats.PurgedArchive);
Assert.Equal(0, stats.PurgedTrash);
Assert.Equal(0, stats.PurgedRejected);
// Старые карточки доски и inbox → archive, is_new=false, archived_at≈now, matchHits пусто.
Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_board_old"));
Assert.False(IsNewOf(store, "l_board_old"));
Assert.Empty(MatchHitsOf(store, "l_board_old"));
DateTimeOffset? archivedAt = store.ArchivedAtOf("l_board_old");
Assert.NotNull(archivedAt);
Assert.True(DateTimeOffset.UtcNow - archivedAt!.Value < TimeSpan.FromMinutes(1), "archived_at ≈ now");
Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_inbox_old"));
// Молодые карточки остались на своих местах (is_new и archived_at не тронуты).
Assert.Equal("b_1", ColOf(store, "l_board_young"));
Assert.Equal(KanbanColumns.Inbox, ColOf(store, "l_fresh"));
Assert.Null(store.ArchivedAtOf("l_fresh"));
}
[Fact]
public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.AutoArchive, "false");
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(0, stats.Archived);
Assert.Equal("b_1", ColOf(store, "l_old"));
Assert.Null(store.ArchivedAtOf("l_old"));
}
[Fact]
public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveAfterDays, "30");
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
store.SeedCard(Card("l_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(2))));
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(1, stats.Archived);
Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_older_month"));
Assert.Equal("b_1", ColOf(store, "l_young"));
}
[Fact]
public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveAfterDays, "1");
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(1, stats.Archived);
Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_inbox_old"));
}
[Fact]
public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20))));
StorageTickStatsDto first = await service.TickAsync(CancellationToken.None);
StorageTickStatsDto second = await service.TickAsync(CancellationToken.None);
Assert.Equal(1, first.Archived);
Assert.Equal(0, second.Archived); // карточка уже в archive — не кандидат автоархива
Assert.Equal(0, second.PurgedArchive); // archived_at свежий (~now), до 90 дней не дошёл
Assert.Equal(0, second.PurgedTrash);
Assert.Equal(KanbanColumns.Archive, ColOf(store, "l_old"));
}
[Fact]
public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30))));
store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30))));
store.SetArchivedAt("l_fresh", now.AddDays(-1)); // свежий — остаётся
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(0, stats.Archived); // колонка archive — не кандидат автоархива
Assert.Equal(1, stats.PurgedArchive);
Assert.Equal(0, stats.PurgedTrash);
Assert.Equal(0, stats.PurgedRejected);
Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_expired");
Assert.Contains(store.CardDtos, card => card.Id == "l_fresh");
}
[Fact]
public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveClearDays, "30");
DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
store.SetArchivedAt("l_expired", now.AddDays(-40)); // старше 30 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
store.SetArchivedAt("l_fresh", now.AddDays(-10)); // моложе 30 дн.
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(1, stats.PurgedArchive);
Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_expired");
Assert.Contains(store.CardDtos, card => card.Id == "l_fresh");
}
[Fact]
public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); // старше 7 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6))));
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(0, stats.Archived);
Assert.Equal(0, stats.PurgedArchive);
Assert.Equal(1, stats.PurgedTrash);
Assert.Equal(0, stats.PurgedRejected);
Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_old");
Assert.Contains(store.CardDtos, card => card.Id == "l_fresh");
}
[Fact]
public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.TrashClearDays, "3");
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1))));
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(1, stats.PurgedTrash);
Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_old");
Assert.Contains(store.CardDtos, card => card.Id == "l_fresh");
}
// ─── Пустой тик / статистика ────────────────────────────────────────────
[Fact]
public async Task Tick_EmptyStore_ReturnsZeroStats()
{
(StorageTickService service, _, _) = Create();
StorageTickStatsDto stats = await service.TickAsync(CancellationToken.None);
Assert.Equal(0, stats.Archived);
Assert.Equal(0, stats.PurgedArchive);
Assert.Equal(0, stats.PurgedTrash);
Assert.Equal(0, stats.PurgedRejected); // отсев пайплайна — этап 4, в этапе 3 всегда 0 (Ruling 8)
}
// ─── Хелперы ────────────────────────────────────────────────────────────
private static (StorageTickService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
return (new StorageTickService(store, settings), store, settings);
}
// Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка).
private static ContainerDto Board(string id) => new() { Id = id, Name = id, Order = 0 };
// Карточка в колонке с моментом получения (epoch-ms).
private static CardDto Card(
string id,
string col,
long receivedAtMs) =>
new() { Id = id, Col = col, ReceivedAtMs = receivedAtMs };
// Epoch-ms момента «now возраст» (ReceivedAt карточки-кандидата).
private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds();
// Колонка карточки в фейке после тика.
private static string ColOf(FakeKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).Col;
// Флаг is_new карточки после тика.
private static bool IsNewOf(FakeKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).IsNew;
private static IReadOnlyList<MatchHitDto> MatchHitsOf(FakeKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).MatchHits;
}
@@ -0,0 +1,316 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
namespace Deal.Tests.Unit.Modules.Kanban;
/// <summary>
/// Тесты чистого ядра ИИ-предложений <see cref="SuggestHeuristics"/>.
/// </summary>
public sealed class SuggestHeuristicsTests
{
// Карточка «Неразобранного» с текстом (helper; время — порядок окна, по умолчанию 0).
// id: Id карточки.
// sourceText: Исходное сообщение.
// receivedAtMs: Время получения, epoch-ms.
// Возвращает: Карточка для входа PlanColumns.
private static CardDto InboxCard(
string id,
string sourceText,
long receivedAtMs = 0) => new()
{
Id = id,
Col = KanbanColumns.Inbox,
Content = new SourceContent { Text = sourceText },
ReceivedAtMs = receivedAtMs,
};
// ─── Порог MIN_INBOX ────────────────────────────────────────────────────
[Fact]
public void PlanColumns_LessThanMinInbox_ReturnsNoPlans()
{
List<CardDto> inbox =
[
InboxCard("l_1", "нужен python"),
InboxCard("l_2", "нужен python"),
InboxCard("l_3", "нужен python"),
InboxCard("l_4", "нужен python"),
InboxCard("l_5", "нужен python"),
];
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, Array.Empty<string>());
Assert.Empty(plans);
}
// ─── Частотные слова-темы и группы ─────────────────────────────────────
[Fact]
public void PlanColumns_SharedThemes_ReturnsGroupsByWords()
{
List<CardDto> inbox =
[
InboxCard("l_py1", "нужен python"),
InboxCard("l_py2", "нужен python"),
InboxCard("l_py3", "нужен python"),
InboxCard("l_t1", "ищу такси"),
InboxCard("l_t2", "ищу такси"),
InboxCard("l_t3", "ищу такси"),
];
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, Array.Empty<string>());
SuggestedColumnPlan python = Assert.Single(plans, plan => plan.Word == "python");
Assert.Equal("Python", python.Name);
Assert.Equal(["l_py1", "l_py2", "l_py3"], python.CardIds);
Assert.Equal("Эвристика (этап 3): слово-тема «python» встречается у 3 карточек; "
+ "реальные предложения ИИ — этап 6", python.Note);
SuggestedColumnPlan taxi = Assert.Single(plans, plan => plan.Word == "такси");
Assert.Equal("Такси", taxi.Name);
Assert.Equal(["l_t1", "l_t2", "l_t3"], taxi.CardIds);
Assert.Equal(2, plans.Count);
}
[Fact]
public void PlanColumns_StopWordsDoNotBecomeThemes()
{
// Единственное общее слово карточек — стоп-слово «нужен» (запрос-глагол); у каждой карточки
// свой уникальный токен частоты 1. Если бы стоп-слова не отсекались, «нужен» (6 карточек) дал бы план.
List<CardDto> inbox =
[
InboxCard("l_1", "нужен python"),
InboxCard("l_2", "нужен vue"),
InboxCard("l_3", "нужен crm"),
InboxCard("l_4", "нужен sql"),
InboxCard("l_5", "нужен php"),
InboxCard("l_6", "нужен mvp"),
];
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, Array.Empty<string>());
Assert.Empty(plans);
}
[Fact]
public void PlanColumns_TopicInSingleCard_DoesNotFormGroup()
{
// Слово частоты 1 (карточки l_3..l_6 уникальны) не собирает группу: нужен повтор в ≥2 карточках.
List<CardDto> inbox =
[
InboxCard("l_1", "нужен python"),
InboxCard("l_2", "нужен python"),
InboxCard("l_3", "нужен vue"),
InboxCard("l_4", "нужен crm"),
InboxCard("l_5", "нужен sql"),
InboxCard("l_6", "нужен php"),
];
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, Array.Empty<string>());
SuggestedColumnPlan plan = Assert.Single(plans);
Assert.Equal("python", plan.Word);
Assert.Equal(["l_1", "l_2"], plan.CardIds);
}
[Fact]
public void PlanColumns_NoSharedWords_ReturnsNoPlans()
{
List<CardDto> inbox =
[
InboxCard("l_1", "нужен python"),
InboxCard("l_2", "нужен vue"),
InboxCard("l_3", "нужен crm"),
InboxCard("l_4", "нужен sql"),
InboxCard("l_5", "нужен php"),
InboxCard("l_6", "нужен mvp"),
];
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, Array.Empty<string>());
Assert.Empty(plans);
}
// ─── Лимит 4 колонок ────────────────────────────────────────────────────
[Fact]
public void PlanColumns_MoreThanFourThemes_LimitsToFourColumns()
{
List<CardDto> inbox =
[
InboxCard("l_c1", "нужен crm"),
InboxCard("l_c2", "нужен crm"),
InboxCard("l_p1", "нужен php"),
InboxCard("l_p2", "нужен php"),
InboxCard("l_v1", "нужен vue"),
InboxCard("l_v2", "нужен vue"),
InboxCard("l_s1", "нужен sql"),
InboxCard("l_s2", "нужен sql"),
InboxCard("l_m1", "нужен mvp"),
InboxCard("l_m2", "нужен mvp"),
];
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, Array.Empty<string>());
Assert.Equal(["crm", "mvp", "php", "sql"], plans.Select(plan => plan.Word));
}
[Fact]
public void PlanColumns_SimilarExistingBoard_SkipsTheWord()
{
List<CardDto> inbox =
[
InboxCard("l_1", "нужен python"),
InboxCard("l_2", "нужен python"),
InboxCard("l_3", "нужен python"),
InboxCard("l_4", "нужен python"),
InboxCard("l_5", "нужен python"),
InboxCard("l_6", "нужен python"),
];
IReadOnlyList<SuggestedColumnPlan> plans =
SuggestHeuristics.PlanColumns(inbox, new[] { "Python разработка" });
Assert.Empty(plans);
}
// ─── Окно MAX_TEXT: анализ только свежих 12 карточек ───────────────────
[Fact]
public void PlanColumns_OnlyNewestMaxTextCards_Analyzed()
{
var inbox = new List<CardDto>();
for (int i = 1; i <= 6; i++)
{
inbox.Add(InboxCard($"l_old{i}", "нужен python", receivedAtMs: i));
}
string[] uniqueWords = ["vue", "crm", "sql", "php", "mvp", "go", "dev", "qa", "ux", "hr", "pr", "seo"];
for (int i = 0; i < uniqueWords.Length; i++)
{
inbox.Add(InboxCard($"l_new{i + 1}", $"нужен {uniqueWords[i]}", receivedAtMs: 100 + i));
}
IReadOnlyList<SuggestedColumnPlan> plans = SuggestHeuristics.PlanColumns(inbox, Array.Empty<string>());
Assert.Empty(plans);
}
[Fact]
public void PlanColumns_SameInput_ReturnsSameOutput()
{
List<CardDto> inbox =
[
InboxCard("l_p1", "нужен python", receivedAtMs: 30),
InboxCard("l_t1", "ищу такси", receivedAtMs: 20),
InboxCard("l_p2", "нужен python", receivedAtMs: 40),
InboxCard("l_t2", "ищу такси", receivedAtMs: 10),
InboxCard("l_p3", "нужен python", receivedAtMs: 50),
InboxCard("l_t3", "ищу такси", receivedAtMs: 5),
];
IReadOnlyList<SuggestedColumnPlan> forward = SuggestHeuristics.PlanColumns(inbox, Array.Empty<string>());
IReadOnlyList<SuggestedColumnPlan> reversed = SuggestHeuristics.PlanColumns(
inbox.AsEnumerable().Reverse().ToList(),
Array.Empty<string>());
Assert.Equal(Snapshot(forward), Snapshot(reversed));
}
// ─── Suggest-keywords: частотные маркеры ───────────────────────────────
[Fact]
public void SuggestDomainKeywords_RepeatedWords_ReturnsMarkers()
{
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(
[
"нужен python",
"нужен python",
"нужен python",
"ищу такси",
"ищу такси",
"ищу такси",
]);
// Слова-маркеры в нижнем регистре, стоп-слова исключены; порядок — частота, затем длина.
Assert.Equal(["python", "такси"], keywords);
}
[Fact]
public void SuggestDomainKeywords_WordCountsPerTextNotPerOccurrence()
{
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(
[
"нужен python python срочно",
"нужен python",
"нужен crm",
]);
Assert.Equal(["python"], keywords);
}
[Fact]
public void SuggestDomainKeywords_NoRepeatedWords_ReturnsEmpty()
{
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(
[
"нужен python",
"нужен vue",
"нужен crm",
"нужен sql",
]);
Assert.Empty(keywords);
}
[Fact]
public void SuggestDomainKeywords_LimitsTotalMarkers()
{
// 70 уникальных слов, каждое в 2 текстах (частота 2): маркеров 70 > лимита 60 — остаётся 60.
string[] prefixes = ["маркер", "заказ", "клиент", "проект", "бота", "телеграм", "сайт", "лендинг", "прилож", "москв"];
string[] suffixes = ["а", "б", "в", "г", "д", "е", "ж"];
var texts = new List<string>();
foreach (string prefix in prefixes)
{
foreach (string suffix in suffixes)
{
string word = prefix + suffix;
texts.Add($"нужен {word}");
texts.Add($"нужен {word}");
}
}
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(texts);
Assert.Equal(SuggestHeuristics.MaxKeywordsTotal, keywords.Count);
Assert.Equal(keywords.Count, keywords.Distinct(StringComparer.Ordinal).Count());
Assert.All(keywords, keyword => Assert.InRange(keyword.Length, 0, SuggestHeuristics.MaxKeywordLength));
}
[Fact]
public void SuggestDomainKeywords_WordLongerThanLimit_IsNotMarker()
{
const string longWord = "супердлинныймаркердляпроверкиограничениядлиныслова";
Assert.True(longWord.Length > SuggestHeuristics.MaxKeywordLength);
IReadOnlyList<string> keywords = SuggestHeuristics.SuggestDomainKeywords(
[
$"нужен {longWord}",
$"нужен {longWord}",
$"нужен {longWord}",
]);
Assert.Empty(keywords);
}
// Детерминированный снимок планов для сравнения (Word/Name/Note + CardIds по порядку).
// plans: Планы колонок.
// Возвращает: Список кортежей-снимков (порядок значим).
private static List<(string Word, string Name, string Note, string Cards)> Snapshot(IReadOnlyList<SuggestedColumnPlan> plans) =>
plans
.Select(plan => (plan.Word, plan.Name, plan.Note, string.Join(",", plan.CardIds)))
.ToList();
}
@@ -0,0 +1,104 @@
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
namespace Deal.Tests.Unit.Modules.Pipeline;
/// <summary>
/// Тесты глобальных исключений
/// </summary>
public sealed class GlobalExclusionRulesTests
{
private static GlobalExcludeSettings Settings(
string[]? keywords = null,
string[]? locations = null,
string[]? types = null,
double? budgetFrom = null,
double? budgetTo = null) =>
new(
keywords ?? Array.Empty<string>(),
locations ?? Array.Empty<string>(),
types ?? Array.Empty<string>(),
budgetFrom,
budgetTo);
[Fact]
public void Match_Keyword_ReturnsKeywordFinding()
{
GlobalExclusionResult? result = GlobalExclusionRules.Match(
"Продам крипто-курс, пассивный доход", Settings(keywords: new[] { "крипт" }));
Assert.NotNull(result);
Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind);
Assert.Equal("крипт", result.Kw);
}
[Fact]
public void Match_Keyword_CaseInsensitive()
{
GlobalExclusionResult? result = GlobalExclusionRules.Match(
"PROMO Krypto project", Settings(keywords: new[] { "krypto" }));
Assert.NotNull(result);
Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind);
}
[Fact]
public void Match_Location_ReturnsLocationFinding()
{
GlobalExclusionResult? result = GlobalExclusionRules.Match(
"Работа в Милане, офис", Settings(locations: new[] { "милан" }));
Assert.NotNull(result);
Assert.Equal(GlobalExclusionRules.KindLocation, result!.Kind);
}
[Fact]
public void Match_Type_ExpandsSynonyms()
{
GlobalExclusionResult? result = GlobalExclusionRules.Match(
"Вакансия: python-разработчик", Settings(types: new[] { "vacancy" }));
Assert.NotNull(result);
Assert.Equal(GlobalExclusionRules.KindType, result!.Kind);
Assert.Equal("vacancy", result.Kw);
}
[Fact]
public void Match_BudgetInRange_ReturnsBudgetFinding()
{
GlobalExclusionResult? result = GlobalExclusionRules.Match(
"Проект на 3000$ — срочно", Settings(budgetFrom: 1000, budgetTo: 5000));
Assert.NotNull(result);
Assert.Equal(GlobalExclusionRules.KindBudget, result!.Kind);
}
[Fact]
public void Match_BudgetOutOfRange_ReturnsNull()
{
GlobalExclusionResult? result = GlobalExclusionRules.Match(
"Проект на 9000$ — срочно", Settings(budgetFrom: 1000, budgetTo: 5000));
Assert.Null(result);
}
[Fact]
public void Match_NoRules_ReturnsNull()
{
GlobalExclusionResult? result = GlobalExclusionRules.Match(
"Обычное сообщение про разработку", Settings());
Assert.Null(result);
}
[Fact]
public void Match_KeywordTakesPrecedenceOverLocation()
{
GlobalExclusionResult? result = GlobalExclusionRules.Match(
"Крипто-вакансия в Милане",
Settings(keywords: new[] { "крипто" }, locations: new[] { "милан" }));
Assert.NotNull(result);
Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind);
}
}
@@ -0,0 +1,166 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline;
/// <summary>
/// Тесты приёма входящих сообщений — PipelineIngestService.
/// </summary>
public sealed class PipelineIngestServiceTests
{
// ─── Приём: нормализация и строка очереди ───────────────────────────────
[Fact]
public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
PipelineIngestResultDto result = await service.EnqueueAsync(
Message(
" Нужен Python-разработчик, удалёнка ",
origin: "d_1",
externalId: "42",
name: "Канал",
hue: "#ababab",
receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(1_000)),
CancellationToken.None);
Assert.StartsWith(PipelineIdPrefixes.Queue, result.Id);
Assert.False(result.Duplicate);
QueueItemDto item = Assert.Single(store.Queue);
Assert.Equal(result.Id, item.Id);
Assert.Equal("d_1", item.Source.OriginRef);
Assert.Equal("42", item.Source.ExternalId);
Assert.Equal("Канал", item.Source.DisplayName);
Assert.Equal("#ababab", item.Source.ResolveHue());
Assert.Equal("Нужен Python-разработчик, удалёнка", item.Content.Text); // trim краёв (enqueue L68)
Assert.Equal("Нужен Python-разработчик, удалёнка", item.Text);
Assert.Equal(PipelineQueueStatuses.New, item.Status);
Assert.Equal(1_000, item.MsgAtMs);
Assert.True(item.QueuedAtMs > 0, "queuedAt = now (CreatedAt/UpdatedAt строки)");
Assert.False(item.Force);
}
[Fact]
public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
await service.EnqueueAsync(
Message("Сообщение без времени", receivedAt: default(DateTimeOffset)),
CancellationToken.None);
QueueItemDto item = Assert.Single(store.Queue);
Assert.Equal(0, item.MsgAtMs); // нет времени источника — нулевая отметка (Ruling 2)
Assert.True(item.QueuedAtMs > 0, "queuedAt = now");
}
[Fact]
public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None);
Assert.True(Assert.Single(store.Queue).Force);
}
// ─── Приём: no-op (пустой текст) ────────────────────────────────────────
[Fact]
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None);
PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None);
Assert.Null(empty.Id);
Assert.False(empty.Duplicate);
Assert.Null(spaces.Id);
Assert.Empty(store.Queue); // пустой текст — no-op (enqueue L69)
}
// ─── Приём: дубль-гвард (ключ записи источника) ─────────────────────────
[Fact]
public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
PipelineIngestResultDto accepted = await service.EnqueueAsync(first, CancellationToken.None);
PipelineIngestResultDto duplicate = await service.EnqueueAsync(second, CancellationToken.None);
Assert.NotNull(accepted.Id);
Assert.Null(duplicate.Id);
Assert.True(duplicate.Duplicate); // повтор записи источника не пишем
Assert.Single(store.Queue);
Assert.Equal("Сообщение", Assert.Single(store.Queue).Text); // первая вставка не перезаписана
}
[Fact]
public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None);
Assert.NotNull(second.Id);
Assert.Equal(2, store.Queue.Count); // гвард — по ключу источника, разные записи проходят
}
[Fact]
public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
PipelineIngestResultDto first = await service.EnqueueAsync(
Message("Первое", origin: "d_1", externalId: null), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(
Message("Второе", origin: "d_1", externalId: null), CancellationToken.None);
Assert.NotNull(first.Id);
Assert.True(second.Duplicate); // ключ источника без внешнего id совпадает
Assert.Single(store.Queue);
}
// ─── Приём: лимит текста 6000 (text[:6000]) ─────────────────────────────
[Fact]
public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
string longText = new string('а', 7_000);
await service.EnqueueAsync(Message(longText), CancellationToken.None);
QueueItemDto item = Assert.Single(store.Queue);
Assert.Equal(6_000, item.Text.EnumerateRunes().Count()); // python text[:6000] — кодовые точки
}
// ─── Помощники ──────────────────────────────────────────────────────────
// Команда приёма со значениями по умолчанию (сценарий теста перекрывает нужные поля).
private static QueuedMessage Message(
string text,
string? origin = SourceItemFactory.Origin,
string? externalId = SourceItemFactory.ExternalId,
string? name = SourceItemFactory.Name,
string? hue = SourceItemFactory.Hue,
DateTimeOffset? receivedAt = null,
bool force = false) => SourceItemFactory.Message(text, origin, externalId, name, hue, receivedAt, force);
}
@@ -0,0 +1,451 @@
using System.Globalization;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline;
/// <summary>
/// Тесты обслуживания пайплайна — PipelineProcessingService.
/// </summary>
public sealed class PipelineProcessingServiceTests
{
[Fact]
public async Task RejectAsync_WhitespaceText_Noop()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None);
Assert.Empty(store.Rejected); // record L7374: пустой текст — no-op
}
[Fact]
public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
await service.RejectAsync(new RejectRecord
{
Source = SourceItemFactory.Source(origin: "d_1", externalId: "7", name: string.Empty, hue: null),
Content = SourceItemFactory.Content(new string('а', 7_000), string.Empty),
Text = new string('а', 7_000),
Reason = new string('р', 600),
Kw = new string('к', 300),
DecidedBy = "stop",
Stage = "stop",
}, CancellationToken.None);
RejectedItemDto row = Assert.Single(store.Rejected);
Assert.Equal("r_test_d_1_7", row.Id); // детерминированный id по источнику (Ruling 1, L77)
Assert.Equal(6_000, row.Text.EnumerateRunes().Count()); // text[:6000]
Assert.Equal(500, row.Reason.EnumerateRunes().Count()); // reason[:500]
Assert.Equal(200, row.Kw.EnumerateRunes().Count()); // kw[:200]
Assert.Equal("#666", row.Source.ResolveHue()); // hue не задан — дефолт (L93)
Assert.Equal("stop", row.Stage);
Assert.Equal("stop", row.DecidedBy);
}
[Fact]
public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
await service.RejectAsync(Reject("d_1", 7, stage: "length", reason: "короткое"), CancellationToken.None);
await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None);
RejectedItemDto row = Assert.Single(store.Rejected); // повторное отбрасывание обновляет запись (L79–101)
Assert.Equal("r_test_d_1_7", row.Id);
Assert.Equal("stop", row.Stage);
Assert.Equal("стоп-фраза «реклама»", row.Reason);
}
[Fact]
public async Task QueueCountsAsync_CountsNewAndFiltered()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2));
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3));
QueueCountsDto counts = await service.QueueCountsAsync(CancellationToken.None);
Assert.Equal(2, counts.New);
Assert.Equal(1, counts.Ai); // bucket «ai» = статус filtered (L212213)
Assert.Equal(3, counts.Total);
}
[Fact]
public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
PipelineStatsDto stats = await service.StatsAsync(CancellationToken.None);
Assert.Equal(1, stats.Queue.New);
Assert.Equal(0, stats.Queue.Ai);
Assert.Equal(1, stats.Queue.Total);
Assert.Equal(2, stats.Rejected); // форма /pipeline/stats: {queue:{new,ai,total}, rejected} (L315320)
}
[Fact]
public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 30));
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10));
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20));
IReadOnlyList<QueueItemDto> clamped = await service.ListQueueAsync(limit: 0, CancellationToken.None);
IReadOnlyList<QueueItemDto> oversized = await service.ListQueueAsync(limit: 600, CancellationToken.None);
QueueItemDto earliest = Assert.Single(clamped); // min(max(1, limit), MAX_LIMIT) L219: 0 → 1
Assert.Equal("p_2", earliest.Id);
Assert.Equal(new[] { "p_2", "p_3", "p_1" }, oversized.Select(item => item.Id).ToArray()); // CreatedAt ASC
}
[Fact]
public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000));
RejectedPageDto page = await service.ListRejectedAsync(q: string.Empty, offset: 1, limit: 1, CancellationToken.None);
Assert.Equal("r_2", Assert.Single(page.Items).Id); // RejectedAt DESC: r_3, r_2, r_1 → страница r_2
Assert.Equal(3, page.Total);
Assert.Equal(1, page.Offset); // эхо запроса
Assert.Equal(1, page.Limit);
}
[Fact]
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000));
store.SeedRejected(Rejected("r_ch", text: "обычный текст", name: "Хороший чат", rejectedAtMs: 4_000));
RejectedPageDto byText = await service.ListRejectedAsync("Python", 0, 10, CancellationToken.None);
RejectedPageDto byReason = await service.ListRejectedAsync("реклама", 0, 10, CancellationToken.None);
RejectedPageDto byKw = await service.ListRejectedAsync("спам", 0, 10, CancellationToken.None);
RejectedPageDto bySource = await service.ListRejectedAsync("хороший", 0, 10, CancellationToken.None);
Assert.Equal("r_text", Assert.Single(byText.Items).Id); // LIKE по text; регистр q не важен
Assert.Equal("r_reason", Assert.Single(byReason.Items).Id); // LIKE по reason
Assert.Equal("r_kw", Assert.Single(byKw.Items).Id); // LIKE по kw
Assert.Equal("r_ch", Assert.Single(bySource.Items).Id); // LIKE по подписи источника
}
[Fact]
public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1", text: "middle a", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000));
store.SeedRejected(Rejected("r_x", text: "другой текст", rejectedAtMs: 4_000));
RejectedPageDto page = await service.ListRejectedAsync("middle", offset: 1, limit: 2, CancellationToken.None);
Assert.Equal(new[] { "r_2", "r_1" }, page.Items.Select(item => item.Id).ToArray()); // кандидаты r_3,r_2,r_1
Assert.Equal(3, page.Total); // total = размер объединения кандидатов (не весь отсев)
}
[Fact]
public async Task ReturnAsync_RecordNotFound_ReturnsNull()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None);
Assert.Null(result); // эндпоинт ответит 404 «Запись не найдена» (Ruling 10)
Assert.Empty(store.Queue);
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task ReturnAsync_AlreadyReturned_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(PipelineProcessingService.AlreadyReturnedDetail, result!.Error); // «Сообщение уже возвращено в обработку»
Assert.False(result.Returned);
Assert.Empty(store.Queue); // повторный возврат ничего не делает
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task ReturnAsync_DuplicateSource_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(PipelineProcessingService.DuplicateReturnDetail, result!.Error); // «Повтор: карточка…»
Assert.Empty(store.Queue);
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task ReturnAsync_EmptyText_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", text: " "));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
Assert.NotNull(result);
Assert.Equal(PipelineProcessingService.EmptyReturnTextDetail, result!.Error); // «В записи нет текста сообщения»
Assert.Empty(store.Queue);
Assert.Empty(ml.Pushed);
}
[Fact]
public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_1",
text: " Продвижение в каналах ",
stage: "spam_ai",
decidedBy: "ai",
origin: "d_9",
externalId: "5",
name: "Спам",
hue: "#ff0000",
msgAtMs: 5_000,
receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(5_000)));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "не спам, вернуть", CancellationToken.None);
Assert.NotNull(result);
Assert.Null(result!.Error);
Assert.Equal("r_1", result.Id);
Assert.True(result.Returned);
Assert.True(result.ReturnedAtMs > 0);
(string text, string label, double delta) = Assert.Single(ml.Pushed); // реальное действие: НЕ спам (L149–153)
Assert.Equal("Продвижение в каналах", text);
Assert.Equal("spam", label);
Assert.Equal(-1.0, delta);
RejectedItemDto record = Assert.Single(store.Rejected); // запись не удаляется — аудит возврата
Assert.True(record.Returned);
Assert.True(record.ReturnedAtMs > 0);
Assert.Equal("не спам, вернуть", record.ReturnReason);
QueueItemDto queued = Assert.Single(store.Queue);
Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id);
Assert.Equal(PipelineQueueStatuses.New, queued.Status);
Assert.True(queued.Force); // возврат: причины отсева игнорируются воркером (Ruling 5/10)
Assert.Equal("d_9", queued.Source.OriginRef);
Assert.Equal("5", queued.Source.ExternalId);
Assert.Equal("Продвижение в каналах", queued.Text);
Assert.Equal("Спам", queued.Source.DisplayName);
Assert.Equal("#ff0000", queued.Source.ResolveHue());
Assert.Equal(5_000, queued.MsgAtMs); // msg_at исходного сообщения сохраняется (L171)
Assert.True(queued.QueuedAtMs > 0);
}
[Fact]
public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_1",
text: "Короткое сообщение",
stage: "length",
decidedBy: "stop",
origin: "d_1",
externalId: "3",
msgAtMs: 9_000,
receivedAt: DateTimeOffset.FromUnixTimeMilliseconds(9_000)));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
Assert.NotNull(result);
Assert.Null(result!.Error);
Assert.True(result.Returned);
Assert.Empty(ml.Pushed); // не «спам»-этап — обучения нет (L149)
QueueItemDto queued = Assert.Single(store.Queue);
Assert.True(queued.Force);
Assert.Equal("Короткое сообщение", queued.Text);
Assert.Equal(9_000, queued.MsgAtMs);
}
[Fact]
public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1"));
await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None);
RejectedItemDto record = Assert.Single(store.Rejected);
Assert.True(record.Returned);
Assert.Equal(500, record.ReturnReason.EnumerateRunes().Count()); // return_reason[:500] (L158)
}
[Fact]
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_old",
text: "Старая запись без ссылки на исходное",
stage: "spam_ml",
decidedBy: "ml",
origin: string.Empty,
externalId: null,
name: string.Empty,
hue: null,
receivedAt: default(DateTimeOffset)));
RejectReturnResultDto? result = await service.ReturnAsync("r_old", string.Empty, CancellationToken.None);
Assert.NotNull(result);
Assert.Null(result!.Error);
Assert.True(result.Returned);
Assert.Single(ml.Pushed); // spam_ml — снятие веса спама идёт до веток возврата
QueueItemDto queued = Assert.Single(store.Queue); // прямая вставка без ссылки на исходное (L174–192)
Assert.StartsWith(PipelineIdPrefixes.Queue, queued.Id);
Assert.True(queued.Force);
Assert.Equal(string.Empty, queued.Source.OriginRef);
Assert.Null(queued.Source.ExternalId);
Assert.Equal("#666", queued.Source.ResolveHue()); // hue не сохранён — дефолт (L181)
Assert.Equal(0, queued.MsgAtMs); // времени источника нет — нулевая отметка
}
[Fact]
public async Task DeleteAsync_RemovesSingleRecord()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
await service.DeleteAsync("r_1", CancellationToken.None);
Assert.Equal("r_2", Assert.Single(store.Rejected).Id);
}
[Fact]
public async Task ClearAsync_ReturnsCountAndEmptiesRejects()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
store.SeedRejected(Rejected("r_3"));
int cleared = await service.ClearAsync(CancellationToken.None);
Assert.Equal(3, cleared); // {ok, cleared} POST /rejected/clear
Assert.Empty(store.Rejected);
Assert.Equal(0, await service.ClearAsync(CancellationToken.None)); // пустой отсев — 0
}
[Fact]
public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
int purged = await service.PurgeExpiredAsync(CancellationToken.None);
Assert.Equal(1, purged); // автоочистка 3 суток от RejectedAt (Ruling 8, purge_expired L104117)
Assert.Equal("r_fresh", Assert.Single(store.Rejected).Id);
}
// ─── Помощники ──────────────────────────────────────────────────────────
// Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария.
private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create()
{
FakePipelineStore store = new();
FakeMlClient ml = new();
PipelineProcessingService service = new(store, ml, new PipelineIngestService(store));
return (service, store, ml);
}
// Команда записи отсева (поля по умолчанию — length/stop, текст без ограничений).
private static RejectRecord Reject(
string dialogId,
long? msgId,
string stage,
string reason) => SourceItemFactory.Reject(
"Текст сообщения",
origin: dialogId,
externalId: msgId?.ToString(CultureInfo.InvariantCulture),
name: string.Empty,
hue: null,
stage: stage,
decidedBy: "stop",
reason: reason);
// Строка очереди для сидирования (id/статус/время постановки).
private static QueueItemDto QueueItem(
string id,
string status,
long queuedAtMs) => SourceItemFactory.Queue(
id,
"текст",
status,
msgAtMs: queuedAtMs,
queuedAtMs: queuedAtMs);
// Запись отсева для сидирования (как строка БД после маппинга адаптера).
private static RejectedItemDto Rejected(
string id,
string text = "Текст сообщения",
string stage = "length",
string decidedBy = "stop",
string reason = "",
string kw = "",
string? origin = SourceItemFactory.Origin,
string? externalId = SourceItemFactory.ExternalId,
string? name = SourceItemFactory.Name,
string? hue = SourceItemFactory.Hue,
long msgAtMs = 0,
long rejectedAtMs = 1_000,
bool returned = false,
string returnReason = "",
DateTimeOffset? receivedAt = null) => SourceItemFactory.Rejected(
id,
text,
stage,
decidedBy,
reason,
kw,
origin,
externalId,
name,
hue,
msgAtMs,
rejectedAtMs,
returned,
returnReason,
receivedAt);
}
@@ -0,0 +1,50 @@
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// In-memory реализация <see cref="IGlobalSettingsStore"/> для юнит-тестов глобальных настроек.
/// </summary>
public sealed class FakeGlobalSettingsStore : IGlobalSettingsStore
{
private readonly Dictionary<string, SettingValue> _rows = new(StringComparer.Ordinal);
public IReadOnlyCollection<string> Keys => _rows.Keys.ToList();
/// <summary>
/// Кладёт готовую строку
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <param name="valueJson">Значение, сериализованное в JSON.</param>
public void Preload(string key, string valueJson)
{
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
}
/// <summary>
/// Возвращает JSON сохранённого значения или null, если ключа нет.
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <returns>JSON-строка значения или null.</returns>
public string? GetStoredJson(string key)
{
return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null;
}
/// <inheritdoc />
public Task<SettingValue?> GetAsync(string key, CancellationToken ct)
{
return Task.FromResult(_rows.TryGetValue(key, out SettingValue? row) ? row : null);
}
/// <inheritdoc />
public Task SetAsync(
string key,
string valueJson,
CancellationToken ct)
{
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
return Task.CompletedTask;
}
}
@@ -0,0 +1,26 @@
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// In-memory реализация <see cref="IRatesChangedListener"/> для тестов триггеров пересчёта.
/// </summary>
public sealed class FakeRatesListener : IRatesChangedListener
{
/// <summary>
/// Вызовы события: fullRecompute каждого оповещения.
/// </summary>
public List<bool> Calls { get; } = [];
/// <summary>
/// Сколько раз слушатель был оповещён.
/// </summary>
public int CallCount => Calls.Count;
/// <inheritdoc />
public Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct)
{
Calls.Add(fullRecompute);
return Task.CompletedTask;
}
}
@@ -0,0 +1,47 @@
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// Фейковый источник курсов для тестов RatesService.
/// </summary>
public sealed class FakeRatesSource : IRatesSource
{
private readonly Dictionary<string, double>? _result;
private readonly Exception? _failure;
/// <summary>
/// Создаёт источник с фиксированным результатом
/// </summary>
/// <param name="result">Курсы к рублю или null, если источник «недоступен».</param>
public FakeRatesSource(Dictionary<string, double>? result)
{
_result = result;
}
/// <summary>
/// Создаёт источник, бросающий исключение
/// </summary>
/// <param name="failure">Исключение, которое бросит FetchAsync.</param>
public FakeRatesSource(Exception failure)
{
_failure = failure;
}
/// <summary>
/// Число вызовов FetchAsync.
/// </summary>
public int Calls { get; private set; }
/// <inheritdoc />
public Task<Dictionary<string, double>?> FetchAsync(CancellationToken ct)
{
Calls++;
if (_failure is not null)
{
return Task.FromException<Dictionary<string, double>?>(_failure);
}
return Task.FromResult(_result);
}
}
@@ -0,0 +1,63 @@
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// In-memory реализация <see cref="ISettingsStore"/> для юнит-тестов SettingsService.
/// </summary>
public sealed class FakeSettingsStore : ISettingsStore
{
private readonly Dictionary<string, SettingValue> _rows = new(StringComparer.Ordinal);
public IReadOnlyCollection<string> Keys => _rows.Keys.ToList();
/// <summary>
/// Кладёт готовую строку
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <param name="valueJson">Значение, сериализованное в JSON.</param>
public void Preload(string key, string valueJson)
{
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
}
/// <summary>
/// Возвращает JSON сохранённого значения или null, если ключа нет.
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <returns>JSON-строка значения или null.</returns>
public string? GetStoredJson(string key)
{
return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null;
}
/// <inheritdoc />
public Task<SettingValue?> GetAsync(string key, CancellationToken ct)
{
return Task.FromResult(_rows.TryGetValue(key, out SettingValue? row) ? row : null);
}
/// <inheritdoc />
public Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyCollection<SettingValue>>(_rows.Values.ToList());
}
/// <inheritdoc />
public Task SetAsync(
string key,
string valueJson,
CancellationToken ct)
{
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveAsync(string key, CancellationToken ct)
{
_rows.Remove(key);
return Task.CompletedTask;
}
}
@@ -0,0 +1,203 @@
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// Тесты границы промптов с фронтом
/// </summary>
public sealed class PromptDefaultsTests
{
private const string DataJsRelativePath = "src/frontend/src/i18n/locales/ru.data.js";
private static readonly string DataJsSource = ReadDataJs();
[Fact]
public void DefaultAiPrompt_MatchDataJs_LineByLine()
{
AssertPromptsIdentical(
"aiPrompt (DEFAULT_AI_PROMPT, data.js L94114)",
ExtractTemplate(DataJsSource, "DEFAULT_AI_PROMPT"),
DefaultPrompts.DefaultAiPrompt);
}
[Fact]
public void DefaultCardPrompt_MatchDataJs_LineByLine()
{
AssertPromptsIdentical(
"cardPrompt (DEFAULT_AI_CARD_PROMPT, data.js L116123)",
ExtractTemplate(DataJsSource, "DEFAULT_AI_CARD_PROMPT"),
DefaultPrompts.DefaultCardPrompt);
}
[Fact]
public void DefaultAiFilterPrompt_MatchDataJs_LineByLine()
{
AssertPromptsIdentical(
"aiFilterPrompt (DEFAULT_AI_FILTER_PROMPT, data.js L125141)",
ExtractTemplate(DataJsSource, "DEFAULT_AI_FILTER_PROMPT"),
DefaultPrompts.DefaultAiFilterPrompt);
}
[Fact]
public void DefaultAiPrompt_ContainsClassifierMarkers()
{
Assert.Contains("Ты — классификатор входящих сообщений", DefaultPrompts.DefaultAiPrompt);
Assert.Contains("{domain}", DefaultPrompts.DefaultAiPrompt);
Assert.Contains("{keywords}", DefaultPrompts.DefaultAiPrompt);
}
[Fact]
public void DefaultCardPrompt_ContainsAboutDealMarker()
{
Assert.Contains("О заявке", DefaultPrompts.DefaultCardPrompt);
}
[Fact]
public void DefaultAiFilterPrompt_ContainsGuardMarkers()
{
Assert.Contains("страж входящих", DefaultPrompts.DefaultAiFilterPrompt);
Assert.Contains("{domain}", DefaultPrompts.DefaultAiFilterPrompt);
Assert.Contains("{keywords}", DefaultPrompts.DefaultAiFilterPrompt);
}
[Fact]
public void Fill_EmptyDomain_UsesFallbackPhrase()
{
Assert.Equal(PromptFiller.FallbackDomain, PromptFiller.Fill("{domain}", null, null));
Assert.Equal(PromptFiller.FallbackDomain, PromptFiller.Fill("{domain}", string.Empty, null));
Assert.Equal(PromptFiller.FallbackDomain, PromptFiller.Fill("{domain}", " ", null));
}
[Fact]
public void Fill_EmptyKeywords_UsesNoKeywordsHint()
{
Assert.Equal(PromptFiller.NoKeywordsHint, PromptFiller.Fill("{keywords}", "сфера", null));
Assert.Equal(PromptFiller.NoKeywordsHint, PromptFiller.Fill("{keywords}", "сфера", Array.Empty<string>()));
Assert.Equal(PromptFiller.NoKeywordsHint, PromptFiller.Fill("{keywords}", "сфера", new[] { " ", "" }));
}
[Fact]
public void Fill_Keywords_AreTrimmedAndJoinedWithComma()
{
string result = PromptFiller.Fill("{keywords}", "сфера", new[] { " бот ", "сайт", "", " парсер " });
Assert.Equal("бот, сайт, парсер", result);
}
[Fact]
public void Fill_MoreThanSixtyKeywords_TakesFirstSixty()
{
string[] keywords = Enumerable.Range(1, 65).Select(index => $"слово-{index}").ToArray();
string result = PromptFiller.Fill("{keywords}", "сфера", keywords);
string expected = string.Join(", ", keywords.Take(PromptFiller.MaxKeywords));
Assert.Equal(expected, result);
Assert.DoesNotContain("слово-61", result);
}
[Fact]
public void Fill_DefaultAiPrompt_ReplacesDomainAndKeywords()
{
string result = PromptFiller.Fill(
DefaultPrompts.DefaultAiPrompt,
"IT-разработка: заказы на сайты и ботов",
new[] { "бот", "сайт", "парсер" });
Assert.Contains("IT-разработка: заказы на сайты и ботов", result);
Assert.Contains("бот, сайт, парсер", result);
Assert.DoesNotContain("{domain}", result);
Assert.DoesNotContain("{keywords}", result);
}
[Fact]
public void Fill_Domain_IsTrimmedBeforeSubstitution()
{
Assert.Equal("IT", PromptFiller.Fill("{domain}", " IT ", null));
}
[Fact]
public void Fill_ReplacesEveryPlaceholderOccurrence()
{
string result = PromptFiller.Fill("{domain} и снова {domain}", "сфера", null);
Assert.Equal("сфера и снова сфера", result);
}
[Fact]
public void Fill_NullOrEmptyPrompt_ReturnsEmptyString()
{
Assert.Equal(string.Empty, PromptFiller.Fill(null, "сфера", null));
Assert.Equal(string.Empty, PromptFiller.Fill(string.Empty, "сфера", null));
}
[Fact]
public void Fill_PromptWithoutPlaceholders_ReturnsPromptUnchanged()
{
const string prompt = "Просто текст без плейсхолдеров";
Assert.Equal(prompt, PromptFiller.Fill(prompt, "сфера", new[] { "слово" }));
}
// ─── Помощники ───────────────────────────────────────────────────────────
private static void AssertPromptsIdentical(
string label,
string expected,
string actual)
{
Assert.True(!expected.EndsWith('\n'), label + ": в data.js текст не оканчивается переводом строки");
Assert.True(!actual.EndsWith('\n'), label + ": DefaultPrompts не должен оканчиваться переводом строки");
string[] expectedLines = expected.Split('\n');
string[] actualLines = actual.Split('\n');
Assert.True(
expectedLines.Length == actualLines.Length,
label + $": число строк отличается (data.js {expectedLines.Length} / DefaultPrompts {actualLines.Length})");
for (int index = 0; index < expectedLines.Length; index++)
{
Assert.True(
string.Equals(expectedLines[index], actualLines[index], StringComparison.Ordinal),
label + $": строка {index + 1} отличается.\n data.js: {Format(expectedLines[index])}\n DefaultPrompts: {Format(actualLines[index])}");
}
}
private static string Format(string text)
{
return "\"" + text.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal) + "\"";
}
private static string ExtractTemplate(string source, string exportName)
{
string marker = $"export const {exportName} = `";
int contentStart = source.IndexOf(marker, StringComparison.Ordinal);
Assert.True(contentStart >= 0, $"data.js: блок {exportName} не найден");
contentStart += marker.Length;
int contentEnd = source.IndexOf('`', contentStart);
Assert.True(contentEnd > contentStart, $"data.js: блок {exportName} не закрыт обратной кавычкой");
return source[contentStart..contentEnd].Replace("\r\n", "\n", StringComparison.Ordinal);
}
private static string ReadDataJs()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Deal.sln")))
{
directory = directory.Parent;
}
Assert.True(directory is not null, "Корень репозитория (папка с Deal.sln) не найден от каталога тестов");
string path = Path.GetFullPath(Path.Combine(directory!.FullName, "..", "..", DataJsRelativePath));
Assert.True(File.Exists(path), $"Интеграционная сверка с фронтом невозможна: не найден {path}");
return File.ReadAllText(path);
}
}
@@ -0,0 +1,204 @@
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// Тесты каталога ключей настроек, дефолтов и провайдеров.
/// </summary>
public sealed class SettingsCatalogTests
{
// Ожидаемый каталог публичных ключей (43 шт.): имя → категория.
private static readonly Dictionary<string, SettingKind> ExpectedPublicKeys = new()
{
// Int
["archiveAfterDays"] = SettingKind.Int,
["archiveClearDays"] = SettingKind.Int,
["trashClearDays"] = SettingKind.Int,
["minLen"] = SettingKind.Int,
["discJoinLimit"] = SettingKind.Int,
["discJoinDelayMin"] = SettingKind.Int,
["discJoinDelayMax"] = SettingKind.Int,
["discEvalSample"] = SettingKind.Int,
["discEvalThreshold"] = SettingKind.Int,
["excludeBudgetFrom"] = SettingKind.Int,
["excludeBudgetTo"] = SettingKind.Int,
// Bool
["autoArchive"] = SettingKind.Bool,
["aiEnabled"] = SettingKind.Bool,
["aiFilterEnabled"] = SettingKind.Bool,
["conversionOn"] = SettingKind.Bool,
["remindersEnabled"] = SettingKind.Bool,
["mlEnabled"] = SettingKind.Bool,
["blockResumes"] = SettingKind.Bool,
["budgetRequiredHire"] = SettingKind.Bool,
["budgetRequiredOrder"] = SettingKind.Bool,
["autoMonitorNew"] = SettingKind.Bool,
["discPaused"] = SettingKind.Bool,
// String
["targetCurrency"] = SettingKind.String,
["rateSource"] = SettingKind.String,
["aiProvider"] = SettingKind.String,
["aiPrompt"] = SettingKind.String,
["aiFilterPrompt"] = SettingKind.String,
["cardPrompt"] = SettingKind.String,
["domainDescription"] = SettingKind.String,
["wantedType"] = SettingKind.String,
["hireLabel"] = SettingKind.String,
["orderLabel"] = SettingKind.String,
// List
["stopPhrases"] = SettingKind.List,
["domainKeywords"] = SettingKind.List,
["hireMarkers"] = SettingKind.List,
["levelTerms"] = SettingKind.List,
["resumeMarkers"] = SettingKind.List,
["excludeKeywords"] = SettingKind.List,
["excludeLocations"] = SettingKind.List,
["excludeTypes"] = SettingKind.List,
// Dict
["colState"] = SettingKind.Dict,
// special
["myPrompts"] = SettingKind.MyPrompts,
["aiConfigs"] = SettingKind.AiConfigs,
};
private const int ExpectedPublicKeysCount = 43;
[Fact]
public void PublicKeys_CoverAllApiMapKeysWithCorrectCategories()
{
Assert.Equal(
ExpectedPublicKeys.OrderBy(pair => pair.Key, StringComparer.Ordinal),
SettingsKeys.PublicKeys.OrderBy(pair => pair.Key, StringComparer.Ordinal));
}
[Fact]
public void PublicKeys_CountMatchesCatalog()
{
Assert.Equal(ExpectedPublicKeysCount, SettingsKeys.PublicKeys.Count);
}
[Fact]
public void InternalKeys_AreNotInPublicCatalog()
{
Assert.DoesNotContain(SettingsKeys.PublicKeys, pair => pair.Key == SettingsKeys.RatesCache);
Assert.DoesNotContain(SettingsKeys.PublicKeys, pair => pair.Key == SettingsKeys.MlDecisions);
Assert.DoesNotContain(SettingsKeys.PublicKeys, pair => pair.Key == SettingsKeys.AiDecisions);
}
[Fact]
public void InternalKeys_HaveExpectedWireNames()
{
Assert.Equal("ratesCache", SettingsKeys.RatesCache);
Assert.Equal("mlDecisions", SettingsKeys.MlDecisions);
Assert.Equal("aiDecisions", SettingsKeys.AiDecisions);
}
[Fact]
public void Providers_IdsAndCountMatchPrototype()
{
string[] expectedIds =
[
"deepseek", "openai", "openrouter", "anthropic", "ollama", "lmstudio", "custom",
];
Assert.Equal(7, AiProviders.All.Count);
Assert.Equal(expectedIds, AiProviders.All.Select(provider => provider.Id));
}
[Fact]
public void Providers_BasesAndLocalFlagsMatchPrototype()
{
var expectedBases = new Dictionary<string, string>
{
["deepseek"] = "https://api.deepseek.com",
["openai"] = "https://api.openai.com/v1",
["openrouter"] = "https://openrouter.ai/api/v1",
["anthropic"] = "https://api.anthropic.com",
["ollama"] = "http://localhost:11434/v1",
["lmstudio"] = "http://localhost:1234/v1",
["custom"] = "https://",
};
Assert.Equal(expectedBases, AiProviders.All.ToDictionary(provider => provider.Id, provider => provider.Base));
Assert.Equal(
new[] { "ollama", "lmstudio" },
AiProviders.All.Where(provider => provider.Local).Select(provider => provider.Id));
}
[Fact]
public void Providers_AnthropicHasAnthropicApiStyleOthersNull()
{
Assert.Equal("anthropic", AiProviders.All.Single(provider => provider.Id == "anthropic").ApiStyle);
Assert.All(
AiProviders.All.Where(provider => provider.Id != "anthropic"),
provider => Assert.Null(provider.ApiStyle));
}
[Fact]
public void Providers_CustomHasEmptyModels()
{
Assert.Empty(AiProviders.All.Single(provider => provider.Id == "custom").Models);
}
[Fact]
public void MockRates_ContainsExpectedCurrencies()
{
string[] expectedCodes = ["RUB", "USD", "EUR", "CNY", "KZT", "BYN", "USDT", "GBP"];
Assert.Equal(
expectedCodes.OrderBy(code => code, StringComparer.Ordinal),
MockRates.Values.Keys.OrderBy(code => code, StringComparer.Ordinal));
Assert.Equal(1.0, MockRates.Values["RUB"]);
Assert.True(MockRates.Values.ContainsKey("USD"));
Assert.True(MockRates.Values.ContainsKey("EUR"));
Assert.True(MockRates.Values.ContainsKey("USDT"));
}
[Fact]
public void Defaults_StoreFilterAndAiValuesMatchTask5Acceptance()
{
Assert.True(SettingsDefaults.AutoArchive);
Assert.Equal(14, SettingsDefaults.ArchiveAfterDays);
Assert.Equal(24, SettingsDefaults.MinLen);
Assert.Equal(4, SettingsDefaults.StopPhrases.Count);
Assert.True(SettingsDefaults.MlEnabled);
Assert.True(SettingsDefaults.AiEnabled);
Assert.True(SettingsDefaults.AiFilterEnabled);
Assert.Equal("both", SettingsDefaults.WantedType);
Assert.True(SettingsDefaults.BlockResumes);
Assert.Equal("RUB", SettingsDefaults.TargetCurrency);
Assert.Equal("cbr", SettingsDefaults.RateSource);
Assert.Equal("deepseek", SettingsDefaults.AiProvider);
Assert.False(SettingsDefaults.DiscPaused);
Assert.Empty(SettingsDefaults.ColState);
Assert.Empty(SettingsDefaults.MyPrompts);
}
[Fact]
public void Defaults_AiConfigsSeededForEveryProviderWithEmptyKey()
{
Assert.Equal(AiProviders.All.Count, SettingsDefaults.AiConfigs.Count);
foreach (AiProviderDefinition provider in AiProviders.All)
{
AiConfigSetting config = SettingsDefaults.AiConfigs[provider.Id];
Assert.Equal("", config.ApiKey);
Assert.Equal(provider.Base, config.BaseUrl);
Assert.Equal(provider.Models.FirstOrDefault() ?? string.Empty, config.Model);
}
}
[Fact]
public void DefaultPrompts_ContainKeyMarkersFromDataJs()
{
Assert.Contains("Ты — классификатор входящих сообщений", DefaultPrompts.DefaultAiPrompt);
Assert.Contains("{domain}", DefaultPrompts.DefaultAiPrompt);
Assert.Contains("{keywords}", DefaultPrompts.DefaultAiPrompt);
Assert.Contains("О заявке", DefaultPrompts.DefaultCardPrompt);
Assert.Contains("страж входящих", DefaultPrompts.DefaultAiFilterPrompt);
Assert.Contains("{domain}", DefaultPrompts.DefaultAiFilterPrompt);
Assert.Contains("{keywords}", DefaultPrompts.DefaultAiFilterPrompt);
}
}
@@ -0,0 +1,32 @@
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// Стабильность каталога типов событий аудита.
/// </summary>
public sealed class AuditEventsTests
{
[Theory]
[InlineData("tenant_logout", AuditEvents.TenantLogout)]
[InlineData("operator_logout", AuditEvents.OperatorLogout)]
[InlineData("invite_joined", AuditEvents.InviteJoined)]
[InlineData("card_created", AuditEvents.CardCreated)]
[InlineData("card_moved", AuditEvents.CardMoved)]
[InlineData("card_trashed", AuditEvents.CardTrashed)]
[InlineData("card_restored", AuditEvents.CardRestored)]
[InlineData("card_deleted", AuditEvents.CardDeleted)]
[InlineData("card_comment_added", AuditEvents.CardCommentAdded)]
[InlineData("card_reclassified", AuditEvents.CardReclassified)]
[InlineData("container_created", AuditEvents.ContainerCreated)]
[InlineData("container_updated", AuditEvents.ContainerUpdated)]
[InlineData("container_deleted", AuditEvents.ContainerDeleted)]
[InlineData("settings_updated", AuditEvents.SettingsUpdated)]
[InlineData("channel_enabled", AuditEvents.ChannelEnabled)]
[InlineData("channel_created", AuditEvents.ChannelCreated)]
[InlineData("telegram_linked", AuditEvents.TelegramLinked)]
public void NewCatalogValues_AreStableStrings(string expected, string actual)
{
Assert.Equal(expected, actual);
}
}
@@ -0,0 +1,184 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// Юнит-тесты AuditService
/// </summary>
public sealed class AuditServiceTests
{
private const string TestIp = "127.0.0.1";
[Fact]
public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields()
{
var store = new FakeAuditLogStore();
var service = new AuditService(store);
var actorId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var before = DateTimeOffset.UtcNow;
await service.AppendAsync(new AuditRecordDto(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
ActorId: actorId,
TenantId: tenantId,
Ip: TestIp,
DetailJson: """{"login":"admin"}"""), CancellationToken.None);
var record = Assert.Single(store.Records);
Assert.Equal(AuditEvents.TenantLoginOk, record.EventType);
Assert.Equal(AuditActorTypes.Tenant, record.ActorType);
Assert.Equal(actorId, record.ActorId);
Assert.Equal(tenantId, record.TenantId);
Assert.Equal(TestIp, record.Ip);
Assert.Equal("""{"login":"admin"}""", record.DetailJson);
// At проставил сервис (UTC-now), а не вызывающий.
Assert.InRange(record.At, before.AddSeconds(-1), DateTimeOffset.UtcNow.AddSeconds(1));
Assert.Equal(1, record.Id);
}
[Fact]
public async Task QueryAsync_ReturnsFilteredRecordsNewestFirst()
{
var store = new FakeAuditLogStore();
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-2));
await AppendWithAtAsync(store, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-1));
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow);
var service = new AuditService(store);
var all = await service.QueryAsync(EmptyFilter(limit: 100), CancellationToken.None);
Assert.Equal(3, all.Count);
// Новые сверху (At DESC).
Assert.Equal(AuditEvents.TenantLoginOk, all[0].EventType);
Assert.Equal(AuditEvents.OperatorLoginOk, all[2].EventType);
var byEvent = await service.QueryAsync(
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, null, null, 100), CancellationToken.None);
Assert.Single(byEvent);
var operatorOnly = await service.QueryAsync(
new AuditQueryDto(null, AuditActorTypes.Operator, null, null, null, 100), CancellationToken.None);
Assert.Equal(AuditEvents.OperatorLoginOk, Assert.Single(operatorOnly).EventType);
var tenantOnly = await service.QueryAsync(
new AuditQueryDto(null, AuditActorTypes.Tenant, null, null, null, 100), CancellationToken.None);
Assert.Equal(2, tenantOnly.Count);
}
[Fact]
public async Task QueryAsync_AppliesAtRangeAndTenantFilters()
{
var store = new FakeAuditLogStore();
var tenantId = Guid.NewGuid();
var outsideTenantId = Guid.NewGuid();
var from = DateTimeOffset.UtcNow.AddMinutes(-10);
var to = DateTimeOffset.UtcNow;
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from.AddMinutes(-1)); // вне окна
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from);
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, outsideTenantId, to);
var service = new AuditService(store);
var byRange = await service.QueryAsync(
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None);
Assert.Equal(2, byRange.Count);
var byRangeAndTenant = await service.QueryAsync(
new AuditQueryDto(AuditEvents.TenantLoginOk, null, tenantId, from, to, 100), CancellationToken.None);
Assert.Equal(AuditEvents.TenantLoginOk, Assert.Single(byRangeAndTenant).EventType);
Assert.Equal(tenantId, byRangeAndTenant[0].TenantId);
}
[Fact]
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
{
var store = new FakeAuditLogStore();
for (int i = 0; i < 7; i++)
{
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow);
}
var service = new AuditService(store);
var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 2);
Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None));
Assert.Equal(2, (await service.QueryAsync(filter, CancellationToken.None)).Count);
}
[Fact]
public void ToDetailJson_SerializesCamelCase()
{
string json = AuditService.ToDetailJson(new { login = "Admin" });
Assert.Contains("\"login\":\"Admin\"", json);
}
[Fact]
public void ActorFromUser_ReturnsTenantActorWithUserAndTenantIds()
{
var userId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var user = new UserIdentityDto(userId, "admin", tenantId, "active");
(string ActorType, Guid? ActorId, Guid? TenantId) actor = AuditService.ActorFromUser(user);
Assert.Equal(AuditActorTypes.Tenant, actor.ActorType);
Assert.Equal(userId, actor.ActorId);
Assert.Equal(tenantId, actor.TenantId);
}
[Fact]
public void ActorFromOperator_ReturnsOperatorActorWithoutTenant()
{
var operatorId = Guid.NewGuid();
var operatorIdentity = new OperatorIdentityDto(operatorId, "operator", "active");
(string ActorType, Guid? ActorId, Guid? TenantId) actor = AuditService.ActorFromOperator(operatorIdentity);
Assert.Equal(AuditActorTypes.Operator, actor.ActorType);
Assert.Equal(operatorId, actor.ActorId);
Assert.Null(actor.TenantId);
}
[Fact]
public void IAuditLogStore_IsAppendOnlyWithoutUpdateOrDelete()
{
string[] methodNames = typeof(IAuditLogStore).GetMethods()
.Where(method => method.DeclaringType == typeof(IAuditLogStore))
.Select(method => method.Name)
.OrderBy(name => name)
.ToArray();
Assert.Equal(["AppendAsync", "CountAsync", "PurgeOlderThanAsync", "QueryAsync"], methodNames);
}
[Fact]
public void AuditService_HasNoUpdateOrDeleteMethods()
{
Assert.DoesNotContain(typeof(AuditService).GetMethods(), method => method.Name.Contains("Update", StringComparison.Ordinal));
Assert.DoesNotContain(typeof(AuditService).GetMethods(), method => method.Name.Contains("Delete", StringComparison.Ordinal));
}
// Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров).
private static async Task AppendWithAtAsync(
FakeAuditLogStore store,
string eventType,
string actorType,
Guid? tenantId,
DateTimeOffset at)
{
await store.AppendAsync(new AuditRecordDto(
eventType,
actorType,
ActorId: null,
TenantId: tenantId,
Ip: TestIp,
DetailJson: null,
At: at), CancellationToken.None);
}
private static AuditQueryDto EmptyFilter(int limit) =>
new(EventType: null, ActorType: null, TenantId: null, From: null, To: null, Limit: limit);
}
@@ -0,0 +1,360 @@
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// Юнит-тесты AuthService на fake-хранилище и детерминированном «хэшере».
/// </summary>
public sealed class AuthServiceTests
{
private const string UserLogin = "admin";
private const string UserPassword = "old-password";
private const string NewPassword = "new-password";
private const string OldSessionRawToken = "old-session-raw-token";
private static readonly Guid UserId = Guid.NewGuid();
private static readonly Guid UserTenantId = Guid.NewGuid();
private readonly FakeAuthStore _store;
private readonly FakeTenantStore _tenantStore;
private readonly AuthService _service;
public AuthServiceTests()
{
var passwordHasher = TestHashers.New();
_store = new FakeAuthStore();
_tenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
_store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
_service = new AuthService(_store, passwordHasher, _tenantStore);
}
[Fact]
public async Task LoginAsync_WithValidCredentials_ReturnsTokenAndCreatesSession()
{
var result = await _service.LoginAsync(" Admin ", UserPassword, CancellationToken.None);
Assert.Equal(UserLogin, result.Login);
Assert.False(string.IsNullOrWhiteSpace(result.Token));
var session = Assert.Single(_store.Sessions);
Assert.Equal(SessionTokens.HashToken(result.Token!), session.TokenHash);
Assert.Equal(UserId, session.UserId);
Assert.True(session.ExpiresAt > DateTimeOffset.UtcNow);
Assert.Equal(new[] { $"create-session:{UserId}" }, _store.Calls);
}
[Fact]
public async Task LoginAsync_WithWrongPassword_ReturnsNullLoginAndToken()
{
var result = await _service.LoginAsync(UserLogin, "wrong-password", CancellationToken.None);
Assert.Null(result.Login);
Assert.Null(result.Token);
Assert.Empty(_store.Sessions);
}
[Fact]
public async Task LoginAsync_WithUnknownLogin_ReturnsNullLoginAndToken()
{
var result = await _service.LoginAsync("unknown", UserPassword, CancellationToken.None);
Assert.Null(result.Login);
Assert.Null(result.Token);
Assert.Empty(_store.Sessions);
}
[Fact]
public async Task ChangePasswordAsync_WithWrongOldPassword_ReturnsOldPasswordError()
{
var result = await _service.ChangePasswordAsync(
UserLogin, "wrong-old-password", NewPassword, CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal(ChangePasswordResultDto.ErrorOldPassword, result.Error);
Assert.Null(result.NewToken);
Assert.Empty(_store.Calls);
Assert.Empty(_store.Sessions);
}
[Fact]
public async Task ChangePasswordAsync_WithShortNewPassword_ReturnsTooShortError()
{
var result = await _service.ChangePasswordAsync(
UserLogin, UserPassword, "123", CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal(ChangePasswordResultDto.ErrorTooShort, result.Error);
Assert.Null(result.NewToken);
Assert.Empty(_store.Calls);
Assert.Empty(_store.Sessions);
}
[Fact]
public async Task ChangePasswordAsync_Success_InvalidatesOldSessionsAndCreatesFreshOne()
{
_store.AddSession(new SessionDto(
SessionTokens.HashToken(OldSessionRawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
var result = await _service.ChangePasswordAsync(UserLogin, UserPassword, NewPassword, CancellationToken.None);
Assert.True(result.Ok);
Assert.Null(result.Error);
Assert.False(string.IsNullOrWhiteSpace(result.NewToken));
Assert.Equal(
new[]
{
$"delete-user-sessions:{UserId}",
$"update-password-hash:{UserId}",
$"create-session:{UserId}",
},
_store.Calls);
var session = Assert.Single(_store.Sessions);
Assert.Equal(SessionTokens.HashToken(result.NewToken!), session.TokenHash);
Assert.Equal(UserId, session.UserId);
// Старый токен больше не резолвится, свежий — резолвится на того же пользователя.
Assert.Null(await _service.ResolveSessionAsync(OldSessionRawToken, CancellationToken.None));
var identity = await _service.ResolveSessionAsync(result.NewToken, CancellationToken.None);
Assert.NotNull(identity);
Assert.Equal(UserLogin, identity!.Login);
}
[Fact]
public async Task ResolveSessionAsync_WithExpiredSession_ReturnsNull()
{
string rawToken = "expired-session-raw-token";
_store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(-1)));
var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
Assert.Null(user);
// Очистка «протухших» сессий вызвана и удалила сессию из fake-хранилища.
Assert.Contains("delete-expired", _store.Calls);
Assert.Empty(_store.Sessions);
}
[Fact]
public async Task ResolveSessionAsync_WithValidSession_ReturnsUser()
{
string rawToken = "valid-session-raw-token";
_store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
Assert.NotNull(user);
Assert.Equal(UserId, user!.Id);
Assert.Equal(UserLogin, user.Login);
Assert.Equal(UserTenantId, user.TenantId);
// Живая сессия не триггерит глобальную очистку (Security review: не на каждый запрос).
Assert.DoesNotContain("delete-expired", _store.Calls);
}
[Fact]
public async Task ResolveSessionAsync_WhenTenantSuspended_ReturnsNullImmediately()
{
string rawToken = "suspended-tenant-session";
_store.AddSession(new SessionDto(
SessionTokens.HashToken(rawToken), UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
await _tenantStore.UpdateStatusAsync(UserTenantId, TenantStatuses.Suspended, CancellationToken.None);
Assert.Null(await _service.ResolveSessionAsync(rawToken, CancellationToken.None));
// Строка сессии не удалена: resume возвращает доступ тем же токеном.
await _tenantStore.UpdateStatusAsync(UserTenantId, TenantStatuses.Active, CancellationToken.None);
var resumed = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
Assert.NotNull(resumed);
Assert.Equal(UserId, resumed!.Id);
}
[Fact]
public async Task ResolveSessionAsync_WithoutToken_ReturnsNull()
{
Assert.Null(await _service.ResolveSessionAsync(null, CancellationToken.None));
Assert.Null(await _service.ResolveSessionAsync(" ", CancellationToken.None));
}
[Fact]
public async Task LogoutAsync_WithToken_DeletesSession()
{
string rawToken = "logout-session-raw-token";
string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
await _service.LogoutAsync(rawToken, CancellationToken.None);
Assert.Empty(_store.Sessions);
Assert.Equal(new[] { $"delete-session:{tokenHash}" }, _store.Calls);
}
[Fact]
public async Task LogoutAsync_WithoutToken_IsNoOp()
{
await _service.LogoutAsync(null, CancellationToken.None);
Assert.Empty(_store.Calls);
}
[Fact]
public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession()
{
var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore);
var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None);
Assert.Null(result.Login);
Assert.Null(result.Token);
Assert.Equal(LoginResultDto.ErrorTenantSuspended, result.Error);
Assert.Equal(UserId, result.UserId);
Assert.Equal(UserTenantId, result.TenantId);
Assert.Empty(suspendedStore.Sessions);
}
[Fact]
public async Task LoginAsync_WhenTenantSuspended_WithWrongPassword_ReturnsGenericFailure()
{
// Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает
// приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты).
var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new FakeTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore);
var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None);
Assert.Null(result.Login);
Assert.Null(result.Token);
Assert.Null(result.Error);
Assert.Null(result.UserId);
Assert.Null(result.TenantId);
}
[Fact]
public async Task ImpersonateAsync_ForExistingUser_CreatesTenantSessionWithOperatorMarker()
{
var operatorId = Guid.NewGuid();
var result = await _service.ImpersonateAsync(UserTenantId, UserLogin, operatorId, CancellationToken.None);
Assert.True(result.Ok);
Assert.Null(result.Error);
Assert.False(string.IsNullOrWhiteSpace(result.SessionToken));
Assert.True(result.ExpiresAt > DateTimeOffset.UtcNow);
Assert.Equal(UserLogin, result.Login);
Assert.Equal(UserId, result.UserId);
Assert.Equal(UserTenantId, result.TenantId);
// Сессия — пользователя тенанта (не оператора) с маркером оператора для аудита stopped.
SessionDto session = Assert.Single(_store.Sessions);
Assert.Equal(SessionTokens.HashToken(result.SessionToken!), session.TokenHash);
Assert.Equal(UserId, session.UserId);
Assert.Equal(operatorId, session.ImpersonatedByOperatorId);
Assert.Equal(new[] { $"create-session:{UserId}" }, _store.Calls);
}
[Fact]
public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser()
{
var passwordHasher = TestHashers.New();
var multiUserStore = new FakeAuthStore();
var firstUserId = Guid.NewGuid();
var secondUserId = Guid.NewGuid();
multiUserStore.AddUser(new StoredUserDto(firstUserId, "first@example.com", UserTenantId, "active", passwordHasher.Hash("p1")));
multiUserStore.AddUser(new StoredUserDto(secondUserId, "second@example.com", UserTenantId, "active", passwordHasher.Hash("p2")));
var multiUserService = new AuthService(multiUserStore, passwordHasher, _tenantStore);
// login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере).
var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None);
Assert.True(result.Ok);
Assert.Equal("first@example.com", result.Login);
Assert.Equal(firstUserId, result.UserId);
}
[Fact]
public async Task ImpersonateAsync_WithLoginOfAnotherTenant_ReturnsUserNotFound()
{
var otherTenantId = Guid.NewGuid();
var result = await _service.ImpersonateAsync(UserTenantId, "someone@other-tenant.com", Guid.NewGuid(), CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal(ImpersonationResultDto.ErrorUserNotFound, result.Error);
Assert.Null(result.SessionToken);
Assert.Empty(_store.Sessions);
// Пользователь другого тенанта с тем же логином тоже не резолвится (не раскрываем существование логина).
var passwordHasher = TestHashers.New();
var crossStore = new FakeAuthStore();
crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p")));
var crossService = new AuthService(crossStore, passwordHasher, _tenantStore);
var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None);
Assert.False(cross.Ok);
Assert.Equal(ImpersonationResultDto.ErrorUserNotFound, cross.Error);
Assert.Empty(crossStore.Sessions);
}
[Fact]
public async Task ImpersonateAsync_ForUnknownTenant_ReturnsTenantNotFound()
{
var result = await _service.ImpersonateAsync(Guid.NewGuid(), UserLogin, Guid.NewGuid(), CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal(ImpersonationResultDto.ErrorTenantNotFound, result.Error);
Assert.Empty(_store.Sessions);
}
[Fact]
public async Task ImpersonateAsync_ForTenantWithoutUsers_ReturnsTenantHasNoUsers()
{
var emptyTenantId = Guid.NewGuid();
await _tenantStore.CreateAsync(new TenantRecordDto(emptyTenantId, "Пустой тенант", TenantStatuses.Active, DateTimeOffset.UtcNow), CancellationToken.None);
var result = await _service.ImpersonateAsync(emptyTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None);
Assert.False(result.Ok);
Assert.Equal(ImpersonationResultDto.ErrorTenantHasNoUsers, result.Error);
Assert.Empty(_store.Sessions);
}
[Fact]
public async Task LogoutAsync_ForImpersonationSession_ReturnsStopInfoAndDeletesSession()
{
var operatorId = Guid.NewGuid();
string rawToken = "impersonation-session-raw-token";
string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30), operatorId));
var logout = await _service.LogoutAsync(rawToken, CancellationToken.None);
Assert.NotNull(logout);
Assert.Equal(operatorId, logout!.OperatorId);
Assert.Equal(UserLogin, logout.Login);
Assert.Equal(UserId, logout.UserId);
Assert.Equal(UserTenantId, logout.TenantId);
Assert.Empty(_store.Sessions);
}
[Fact]
public async Task LogoutAsync_ForNormalSession_ReturnsNull()
{
string rawToken = "normal-session-raw-token";
string tokenHash = SessionTokens.HashToken(rawToken);
_store.AddSession(new SessionDto(tokenHash, UserId, UserLogin, DateTimeOffset.UtcNow.AddDays(30)));
var logout = await _service.LogoutAsync(rawToken, CancellationToken.None);
Assert.Null(logout);
Assert.Empty(_store.Sessions);
}
}
@@ -0,0 +1,53 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.SharedKernel.Tenants.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITenantProvisioner"/> для тестов пакетной миграции
/// </summary>
public sealed class FailingTenantProvisioner : ITenantProvisioner
{
private readonly HashSet<string> _failingSchemaNames;
private readonly List<string> _provisionedSchemaNames = [];
private readonly object _gate = new();
/// <summary>
/// Создаёт провижинер, роняющий провижининг указанных схем.
/// </summary>
/// <param name="failingSchemaNames">Имена схем (tenant_&lt;id&gt), провижининг которых бросает исключение.</param>
public FailingTenantProvisioner(params string[] failingSchemaNames)
{
_failingSchemaNames = new HashSet<string>(failingSchemaNames, StringComparer.Ordinal);
}
/// <summary>
/// Имена успешно провижиненных схем в порядке вызовов.
/// </summary>
public IReadOnlyList<string> ProvisionedSchemaNames
{
get
{
lock (_gate)
{
return _provisionedSchemaNames.ToArray();
}
}
}
/// <inheritdoc />
public Task ProvisionAsync(TenantId tenantId, CancellationToken ct)
{
if (_failingSchemaNames.Contains(tenantId.SchemaName))
{
throw new InvalidOperationException($"Тест: схема {tenantId.SchemaName} недоступна");
}
lock (_gate)
{
_provisionedSchemaNames.Add(tenantId.SchemaName);
}
return Task.CompletedTask;
}
}
@@ -0,0 +1,56 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IAuditLogStore"/> для юнит/HTTP-тестов аудита.
/// </summary>
public sealed class FakeAuditLogStore : IAuditLogStore
{
private readonly List<AuditRecordDto> _records = [];
/// <summary>
/// Записи хранилища в порядке добавления
/// </summary>
public IReadOnlyList<AuditRecordDto> Records => _records;
/// <inheritdoc />
public Task AppendAsync(AuditRecordDto record, CancellationToken ct)
{
_records.Add(record with { Id = _records.Count + 1 });
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct)
{
IReadOnlyList<AuditRecordDto> result = ApplyFilters(filter)
.OrderByDescending(r => r.At)
.Skip(Math.Max(0, filter.Offset))
.Take(Math.Max(1, Math.Min(AuditService.MaxQueryLimit, filter.Limit)))
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) =>
Task.FromResult(ApplyFilters(filter).Count());
/// <inheritdoc />
public Task<int> PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct)
{
int removed = _records.RemoveAll(record => record.At < cutoff);
return Task.FromResult(removed);
}
private IEnumerable<AuditRecordDto> ApplyFilters(AuditQueryDto filter) =>
_records.Where(r =>
(filter.EventType is null || r.EventType == filter.EventType) &&
(filter.ActorType is null || r.ActorType == filter.ActorType) &&
(filter.TenantId is null || r.TenantId == filter.TenantId) &&
(filter.ActorId is null || r.ActorId == filter.ActorId) &&
(filter.From is null || r.At >= filter.From.Value) &&
(filter.To is null || r.At <= filter.To.Value));
}
@@ -0,0 +1,123 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IAuthStore"/> для юнит-тестов AuthService.
/// </summary>
public sealed class FakeAuthStore : IAuthStore
{
private readonly List<StoredUserDto> _users = [];
private readonly List<SessionDto> _sessions = [];
private readonly List<string> _calls = [];
/// <summary>
/// Пользователи хранилища.
/// </summary>
public IReadOnlyList<StoredUserDto> Users => _users;
/// <summary>
/// Сессии хранилища.
/// </summary>
public IReadOnlyList<SessionDto> Sessions => _sessions;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Добавляет пользователя
/// </summary>
/// <param name="user">Пользователь.</param>
public void AddUser(StoredUserDto user) => _users.Add(user);
/// <summary>
/// Добавляет сессию
/// </summary>
/// <param name="session">Сессия.</param>
public void AddSession(SessionDto session) => _sessions.Add(session);
/// <inheritdoc />
public Task<StoredUserDto?> FindUserByLoginAsync(string login, CancellationToken ct) =>
Task.FromResult(_users.SingleOrDefault(u => u.Login == login));
/// <inheritdoc />
public Task<SessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) =>
Task.FromResult(_sessions.SingleOrDefault(s => s.TokenHash == tokenHash));
/// <inheritdoc />
public Task<UserIdentityDto?> FindUserByIdAsync(Guid userId, CancellationToken ct)
{
var user = _users.SingleOrDefault(u => u.Id == userId);
return Task.FromResult(
user is null
? null
: new UserIdentityDto(user.Id, user.Login, user.TenantId, user.Status));
}
/// <inheritdoc />
public Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<UserIdentityDto>>(_users
.Where(u => u.TenantId == tenantId)
.Select(u => new UserIdentityDto(u.Id, u.Login, u.TenantId, u.Status))
.ToList());
/// <inheritdoc />
public Task CreateUserAsync(StoredUserDto user, CancellationToken ct)
{
_users.Add(user);
_calls.Add($"create-user:{user.Id}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task CreateSessionAsync(SessionDto session, CancellationToken ct)
{
_sessions.Add(session);
_calls.Add($"create-session:{session.UserId}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
{
_sessions.RemoveAll(s => s.TokenHash == tokenHash);
_calls.Add($"delete-session:{tokenHash}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct)
{
_sessions.RemoveAll(s => s.UserId == userId);
_calls.Add($"delete-user-sessions:{userId}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdatePasswordHashAsync(
Guid userId,
string passwordHash,
CancellationToken ct)
{
int index = _users.FindIndex(u => u.Id == userId);
if (index >= 0)
{
var user = _users[index];
_users[index] = user with { PasswordHash = passwordHash };
}
_calls.Add($"update-password-hash:{userId}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteExpiredSessionsAsync(CancellationToken ct)
{
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
_calls.Add("delete-expired");
return Task.CompletedTask;
}
}
@@ -0,0 +1,84 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IInviteStore"/> для юнит/HTTP-тестов приглашений.
/// </summary>
public class FakeInviteStore : IInviteStore
{
private readonly List<InviteDto> _invites = [];
private readonly List<string> _calls = [];
/// <summary>
/// Приглашения хранилища в порядке добавления.
/// </summary>
public IReadOnlyList<InviteDto> Invites => _invites;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Добавляет приглашение
/// </summary>
/// <param name="invite">Приглашение.</param>
public void AddInvite(InviteDto invite) => _invites.Add(invite);
/// <inheritdoc />
public Task CreateAsync(InviteDto invite, CancellationToken ct)
{
_invites.Add(invite);
_calls.Add($"create:{invite.Code}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<InviteDto?> GetByCodeAsync(string code, CancellationToken ct) =>
Task.FromResult(_invites.SingleOrDefault(i => i.Code == code));
/// <inheritdoc />
public Task<IReadOnlyList<InviteDto>> ListAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<InviteDto>>(_invites.OrderByDescending(i => i.CreatedAt).ToList());
/// <inheritdoc />
public Task<bool> UpdateStatusAsync(
string code,
string status,
DateTimeOffset? activatedAt,
CancellationToken ct)
{
int index = _invites.FindIndex(i => i.Code == code);
if (index < 0)
{
return Task.FromResult(false);
}
_invites[index] = _invites[index] with { Status = status, ActivatedAt = activatedAt };
_calls.Add($"update:{code}:{status}");
return Task.FromResult(true);
}
/// <inheritdoc />
public virtual Task<bool> TryActivateAsync(
string code,
DateTimeOffset activatedAt,
CancellationToken ct)
{
int index = _invites.FindIndex(i => i.Code == code && i.Status == InviteStatuses.Pending);
if (index < 0)
{
return Task.FromResult(false);
}
_invites[index] = _invites[index] with { Status = InviteStatuses.Activated, ActivatedAt = activatedAt };
_calls.Add($"activate:{code}");
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<InviteDto?> FindActiveByEmailAsync(string email, CancellationToken ct) =>
Task.FromResult(_invites.SingleOrDefault(i => i.Email == email && i.Status == InviteStatuses.Pending));
}
@@ -0,0 +1,81 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="IOperatorAuthStore"/> для юнит-тестов OperatorAuthService.
/// </summary>
public sealed class FakeOperatorAuthStore : IOperatorAuthStore
{
private readonly List<StoredOperatorDto> _operators = [];
private readonly List<OperatorSessionDto> _sessions = [];
private readonly List<string> _calls = [];
/// <summary>
/// Операторы хранилища.
/// </summary>
public IReadOnlyList<StoredOperatorDto> Operators => _operators;
/// <summary>
/// Сессии операторов хранилища.
/// </summary>
public IReadOnlyList<OperatorSessionDto> Sessions => _sessions;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Добавляет оператора
/// </summary>
/// <param name="operatorRecord">Оператор.</param>
public void AddOperator(StoredOperatorDto operatorRecord) => _operators.Add(operatorRecord);
/// <summary>
/// Добавляет сессию
/// </summary>
/// <param name="session">Сессия.</param>
public void AddSession(OperatorSessionDto session) => _sessions.Add(session);
/// <inheritdoc />
public Task<StoredOperatorDto?> FindByLoginAsync(string login, CancellationToken ct) =>
Task.FromResult(_operators.SingleOrDefault(o => o.Login == login));
/// <inheritdoc />
public Task<OperatorSessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) =>
Task.FromResult(_sessions.SingleOrDefault(s => s.TokenHash == tokenHash));
/// <inheritdoc />
public Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
{
_operators.Add(operatorRecord);
_calls.Add($"create-operator:{operatorRecord.Id}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct)
{
_sessions.Add(session);
_calls.Add($"create-session:{session.OperatorId}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
{
_sessions.RemoveAll(s => s.TokenHash == tokenHash);
_calls.Add($"delete-session:{tokenHash}");
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteExpiredSessionsAsync(CancellationToken ct)
{
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
_calls.Add("delete-expired");
return Task.CompletedTask;
}
}

Some files were not shown because too many files have changed in this diff Show More