SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,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_<prefix>_<n>).
|
||||
// store: Хранилище-фейк.
|
||||
// count: Число строк.
|
||||
// prefix: Префикс id (различает тенантов теста).
|
||||
private static void SeedRows(
|
||||
FakeMlLearningStore store,
|
||||
int count,
|
||||
string prefix)
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
store.SeedOutbox($"mle_{prefix}_{i:D2}", $"текст {prefix} {i}", i % 2 == 0 ? "b_col" : "spam", 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Запись реестра тенанта (как строка public.tenants).
|
||||
// id: Идентификатор тенанта.
|
||||
// Возвращает: Запись тенанта со статусом active.
|
||||
private static TenantRecordDto Tenant(Guid id) =>
|
||||
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
|
||||
}
|
||||
@@ -0,0 +1,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_<N>).
|
||||
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 L348–349
|
||||
|
||||
CardSnapshot snapshot = await composer.BuildAsync(
|
||||
Parsed(budget: new AiBudgetDto(1500, 2000, "USD")),
|
||||
Message("Ищем разработчика, оплата 1500–2000$"),
|
||||
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); // пустой текст не учим (L190–191: 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 600–2 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 в диапазоне 1000–3000 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 L165–166: в ту же колонку не переносим
|
||||
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 символа (L511–512)
|
||||
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 L186–194)
|
||||
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 L196–203)
|
||||
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 L448–451)
|
||||
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 L291–296)
|
||||
}
|
||||
|
||||
[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 L229–237)
|
||||
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 L417–420)
|
||||
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 L480–481)
|
||||
}
|
||||
|
||||
[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"
|
||||
+ "Бюджет: 1500–2000 $\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"
|
||||
+ "Бюджет: 1500–2000 $\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 L1108–1111)
|
||||
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 L480–482)
|
||||
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 L465–466)
|
||||
}
|
||||
|
||||
[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 L342–344)
|
||||
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 L332–339)
|
||||
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 L376–377)
|
||||
}
|
||||
|
||||
[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 200–1 500 $"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_NumericRangeWithCurrencyWordAfter_ReturnsBounds()
|
||||
{
|
||||
Assert.Equal(new AmountRange(1500, 2500, "RUB"), Single("Бюджет 1500–2500 рублей"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DollarSymbolBeforeRange_IsNotDetected()
|
||||
{
|
||||
Assert.Empty(AmountParser.Parse("Вилка $50–100"));
|
||||
}
|
||||
|
||||
// ─── Суффикс «к/К» и одиночные суммы ───────────────────────────────────
|
||||
|
||||
[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"));
|
||||
|
||||
// $1000–2000 по курсу 92.5 = 92 500–185 000 ₽ — в диапазоне.
|
||||
Assert.True(ColumnRules.Matches(rules, "Бюджет 1000–2000 $", 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, "Бюджет 1000–2000 $"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Matches_Budget_UsdtTreatedAsUsd_ReturnsTrue()
|
||||
{
|
||||
ContainerRulesDto rules = Rules(budget: new BudgetRangeDto(From: 1000, To: 2000, Cur: "USD"));
|
||||
|
||||
Assert.True(ColumnRules.Matches(rules, "Бюджет 1000–2000 ₮", 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(
|
||||
"все условия · слова: бот; исключено: курсы; бюджет: 1000–2000 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 L73–74: пустой текст — 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 (L212–213)
|
||||
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} (L315–320)
|
||||
}
|
||||
|
||||
[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 L104–117)
|
||||
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 L94–114)",
|
||||
ExtractTemplate(DataJsSource, "DEFAULT_AI_PROMPT"),
|
||||
DefaultPrompts.DefaultAiPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultCardPrompt_MatchDataJs_LineByLine()
|
||||
{
|
||||
AssertPromptsIdentical(
|
||||
"cardPrompt (DEFAULT_AI_CARD_PROMPT, data.js L116–123)",
|
||||
ExtractTemplate(DataJsSource, "DEFAULT_AI_CARD_PROMPT"),
|
||||
DefaultPrompts.DefaultCardPrompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultAiFilterPrompt_MatchDataJs_LineByLine()
|
||||
{
|
||||
AssertPromptsIdentical(
|
||||
"aiFilterPrompt (DEFAULT_AI_FILTER_PROMPT, data.js L125–141)",
|
||||
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_<id>), провижининг которых бросает исключение.</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
Reference in New Issue
Block a user