Перевести тестовые фейки на моки NSubstitute #13

Merged
rust merged 12 commits from t1_NSubstitute_tests into main 2026-09-13 03:28:11 +03:00
92 changed files with 1994 additions and 2972 deletions
+17
View File
@@ -1,10 +1,27 @@
name: ci
# Прогоняем CI в двух случаях:
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
# Обычные коммиты в ветки без открытого МР CI не запускают.
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened
workflow_dispatch:
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
# Завершённые (успех/фейл) прогоны не трогаются.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: ubuntu-latest
@@ -20,7 +20,7 @@ public sealed class DiscoveryBanGuard
/// </summary>
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param>
/// <param name="utcNow">Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон FakeDiscoveryStore). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
/// <param name="utcNow">Источник текущего времени (UTC). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
public DiscoveryBanGuard(
IDiscoveryStore store,
ISettingsStore settings,
@@ -24,11 +24,11 @@ public sealed class DataRetentionSchedulerTests
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 audit = new TestAuditLogStore();
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
await audit.Store.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
// Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться.
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true);
@@ -63,9 +63,9 @@ public sealed class DataRetentionSchedulerTests
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();
var audit = new TestAuditLogStore();
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
var limits = new TestTenantLimitStore();
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
DataRetentionScheduler scheduler = new(
@@ -86,13 +86,13 @@ public sealed class DataRetentionSchedulerTests
// counters: Хелпер счётчиков (окна посеяны сценарием).
// Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider(
FakeAuditLogStore audit,
FakeTenantLimitStore limits,
TestAuditLogStore audit,
TestTenantLimitStore limits,
TestRateLimitCounterStore counters)
{
var services = new ServiceCollection();
services.AddScoped<IAuditLogStore>(_ => audit);
services.AddScoped<ITenantLimitStore>(_ => limits);
services.AddScoped<IAuditLogStore>(_ => audit.Store);
services.AddScoped<ITenantLimitStore>(_ => limits.Store);
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
return services.BuildServiceProvider();
}
@@ -94,8 +94,8 @@ public sealed class DiscoveryWorkerSchedulerTests
var tenantContext = new TenantContext();
var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore();
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var settingsA = new TestSettingsStore();
var settingsB = new TestSettingsStore();
var gatewayA = new TestDiscoveryGateway();
var gatewayB = new TestDiscoveryGateway();
PreloadDiscoveryEvalOff(settingsA);
@@ -104,12 +104,12 @@ public sealed class DiscoveryWorkerSchedulerTests
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA.Store : storeB.Store);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddDiscoveryModule();
@@ -124,7 +124,7 @@ public sealed class DiscoveryWorkerSchedulerTests
}
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings)
private static void PreloadDiscoveryEvalOff(TestSettingsStore settings)
{
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
@@ -49,7 +49,7 @@ public sealed class IngressRateLimitInterceptorTests
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
async channel =>
{
PushSourceReply first = await PushAsync(channel, TenantA);
@@ -77,7 +77,7 @@ public sealed class IngressRateLimitInterceptorTests
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
async channel =>
{
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
@@ -39,14 +39,14 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 25, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
@@ -54,7 +54,7 @@ public sealed class MlOutboxFlushSchedulerTests
// 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.Equal(0, await store.Store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход
});
}
@@ -65,23 +65,23 @@ public sealed class MlOutboxFlushSchedulerTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.TrainUnavailable = true;
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 5, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); // строки остались
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None)); // строки остались
// Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены).
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(2, service.TrainCalls);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant);
});
}
@@ -91,16 +91,16 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var storeA = new FakeMlLearningStore();
var storeA = new TestMlLearningStore();
SeedRows(storeA, count: 12, prefix: "a");
var storeB = new FakeMlLearningStore();
var storeB = new TestMlLearningStore();
SeedRows(storeB, count: 3, prefix: "b");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
@@ -108,8 +108,8 @@ public sealed class MlOutboxFlushSchedulerTests
// Каждый тенант выгрузил свою очередь в собственном 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.Equal(0, await storeA.Store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(0, await storeB.Store.CountOutboxAsync(CancellationToken.None));
Assert.False(tenantContext.HasTenant);
});
}
@@ -119,20 +119,20 @@ public sealed class MlOutboxFlushSchedulerTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var store = new FakeMlLearningStore();
var store = new TestMlLearningStore();
SeedRows(store, count: 105, prefix: "a");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(10, service.TrainCalls);
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None));
});
}
@@ -149,7 +149,7 @@ public sealed class MlOutboxFlushSchedulerTests
int port,
TestTenantRepository tenants,
TenantContext tenantContext,
Dictionary<Guid, FakeMlLearningStore> storesByTenant)
Dictionary<Guid, TestMlLearningStore> storesByTenant)
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
@@ -162,9 +162,9 @@ public sealed class MlOutboxFlushSchedulerTests
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<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)].Store);
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<TokenUsageRecorder>();
services.AddLogging();
@@ -196,7 +196,7 @@ public sealed class MlOutboxFlushSchedulerTests
// count: Число строк.
// prefix: Префикс id (различает тенантов теста).
private static void SeedRows(
FakeMlLearningStore store,
TestMlLearningStore store,
int count,
string prefix)
{
@@ -128,13 +128,13 @@ public sealed class OperatorBootstrapHostedServiceTests
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
var passwordHasher = TestHashers.New();
var logger = new ListLogger();
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
// провайдер собирается с реальной регистрацией модуля поверх фейков.
var services = new ServiceCollection();
services.AddScoped<IOperatorAuthStore>(_ => store);
services.AddScoped<IOperatorAuthStore>(_ => store.Store);
services.AddScoped<IPasswordHasher>(_ => passwordHasher);
services.AddScoped<OperatorBootstrapService>();
// Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания.
@@ -150,7 +150,7 @@ public sealed class OperatorBootstrapHostedServiceTests
// Контекст теста bootstrap-шага.
private sealed record Context(
OperatorBootstrapHostedService Hosted,
FakeOperatorAuthStore Store,
TestOperatorAuthStore Store,
IPasswordHasher PasswordHasher,
ListLogger Logs);
@@ -29,19 +29,19 @@ public sealed class RuntimeDepthsCollectorTests
[Fact]
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
{
var pipelineByTenant = new Dictionary<string, FakePipelineStore>
var pipelineByTenant = new Dictionary<string, TestPipelineStore>
{
[TenantA.ToString("N")] = new FakePipelineStore(),
[TenantB.ToString("N")] = new FakePipelineStore(),
[TenantA.ToString("N")] = new TestPipelineStore(),
[TenantB.ToString("N")] = new TestPipelineStore(),
};
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>
var outboxByTenant = new Dictionary<string, TestMlLearningStore>
{
[TenantA.ToString("N")] = new FakeMlLearningStore(),
[TenantB.ToString("N")] = new FakeMlLearningStore(),
[TenantA.ToString("N")] = new TestMlLearningStore(),
[TenantB.ToString("N")] = new TestMlLearningStore(),
};
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
@@ -59,7 +59,7 @@ public sealed class RuntimeDepthsCollectorTests
[Fact]
public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
{
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
DateTimeOffset periodStart = DateTimeOffset.UtcNow;
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
@@ -69,7 +69,7 @@ public sealed class RuntimeDepthsCollectorTests
PipelineStores(TenantA, TenantB, TenantC),
OutboxStores(TenantA, TenantB, TenantC),
TenantRecords(TenantA, TenantB, TenantC),
limits);
limits.Store);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -83,7 +83,7 @@ public sealed class RuntimeDepthsCollectorTests
[Fact]
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
{
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
limits.FailStateReads.Add(TenantA);
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
@@ -91,7 +91,7 @@ public sealed class RuntimeDepthsCollectorTests
PipelineStores(TenantA, TenantB),
OutboxStores(TenantA, TenantB),
TenantRecords(TenantA, TenantB),
limits);
limits.Store);
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -105,8 +105,8 @@ public sealed class RuntimeDepthsCollectorTests
public async Task CollectAsync_NoTenants_ReturnsZeros()
{
RuntimeDepthsCollector collector = Build(
new Dictionary<string, FakePipelineStore>(),
new Dictionary<string, FakeMlLearningStore>(),
new Dictionary<string, TestPipelineStore>(),
new Dictionary<string, TestMlLearningStore>(),
tenants: Array.Empty<TenantRecordDto>());
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
@@ -123,11 +123,11 @@ public sealed class RuntimeDepthsCollectorTests
Status = PipelineQueueStatuses.New,
};
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
private static Dictionary<string, TestPipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestPipelineStore());
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
=> tenants
@@ -139,8 +139,8 @@ public sealed class RuntimeDepthsCollectorTests
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
private static RuntimeDepthsCollector Build(
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
IReadOnlyDictionary<string, TestPipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
IReadOnlyList<TenantRecordDto>? tenants = null,
ITenantLimitStore? limitStore = null)
{
@@ -153,12 +153,12 @@ public sealed class RuntimeDepthsCollectorTests
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
}).ToArray()).Repository);
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)]);
services.AddScoped<IMlClient>(_ => new FakeMlClient());
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)].Store);
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore());
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)].Store);
services.AddSingleton<ITenantLimitStore>(limitStore ?? new TestTenantLimitStore().Store);
ServiceProvider provider = services.BuildServiceProvider();
return new RuntimeDepthsCollector(
@@ -43,7 +43,7 @@ public sealed class TgStatusServiceTests
[Fact]
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys()
{
(TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore 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));
@@ -69,7 +69,7 @@ public sealed class TgStatusServiceTests
[Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
{
(TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
@@ -106,16 +106,16 @@ public sealed class TgStatusServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher).
private static (TgStatusService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
private static (TgStatusService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
{
var store = new TestTelegramStore();
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway();
var cipher = TestCiphers.New();
var globalSettings = new TestGlobalSettingsStore();
var keys = new TelegramKeysService(globalSettings.Store, cipher);
var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys);
var dialogs = new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings.Store, keys);
return (service, store, settings, gateway, cipher, globalSettings);
}
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary>
public sealed class BudgetedAiClassifierTests
{
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита.
@@ -128,7 +128,7 @@ public sealed class BudgetedAiClassifierTests
Context ctx = Create(
limits => limits.Preload(
TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings));
var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings.Store));
const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$";
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
@@ -152,24 +152,24 @@ public sealed class BudgetedAiClassifierTests
private sealed record Context(
IAiClassifier Decorator,
TestAiClassifier Paid,
FakeSettingsStore Settings);
TestSettingsStore Settings);
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
// (зеркало регистрации AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
private static Context Create(Action<TestTenantLimitStore>? configure = null)
{
var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore();
var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new TestAiClassifier();
IAiClassifier decorator = new BudgetedAiClassifier(
paid.Classifier,
new LocalAiClassifier(new LocalFieldsParser(settings)),
limits,
new LocalAiClassifier(new LocalFieldsParser(settings.Store)),
limits.Store,
tenantContext,
NullLogger<BudgetedAiClassifier>.Instance);
return new Context(decorator, paid, settings);
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
/// </summary>
public sealed class BudgetedAiToolsTests
{
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
// Guid того же тенанта — ключ строки лимита.
@@ -139,16 +139,16 @@ public sealed class BudgetedAiToolsTests
// AddDealIntegrations при UseLocal=false).
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
// Возвращает: Контекст теста.
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
private static Context Create(Action<TestTenantLimitStore>? configure = null)
{
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = TestAiTools.New();
IAiTools decorator = new BudgetedAiTools(
paid,
limits,
limits.Store,
tenantContext,
NullLogger<BudgetedAiTools>.Instance);
return new Context(decorator, paid);
@@ -6,7 +6,6 @@ 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;
@@ -109,7 +108,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
CardSnapshot snapshot = await composer.BuildAsync(
@@ -125,7 +124,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" }));
CardSnapshot snapshot = await composer.BuildAsync(
@@ -141,7 +140,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_py", new[] { "python" }));
CardSnapshot snapshot = await composer.BuildAsync(
@@ -193,7 +192,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty()
{
(CardComposer composer, _, FakeSettingsStore settings) = Create();
(CardComposer composer, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348349
CardSnapshot snapshot = await composer.BuildAsync(
@@ -232,11 +231,11 @@ public sealed class CardComposerTests
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create()
private static (CardComposer Composer, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
return (new CardComposer(store, settings), store, settings);
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new CardComposer(store.Store, settings.Store), store, settings);
}
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
@@ -3,7 +3,6 @@ using Deal.Tests.Unit.Support;
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;
@@ -17,7 +16,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100));
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
@@ -30,7 +29,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ListCards_ByColumn_ReturnsOnlyColumnCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
store.SeedCard(Card("l_2", KanbanColumns.Trash));
@@ -43,7 +42,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task GetCard_Existing_ReturnsCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python"));
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
@@ -66,7 +65,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
@@ -84,7 +83,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoard_WritesJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -105,7 +104,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToInbox_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
@@ -124,7 +123,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToUnknownBoard_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
@@ -148,7 +147,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_CardMissing_ReturnsNullLead()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
@@ -162,7 +161,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_SameColumn_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
@@ -179,7 +178,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_free")); // правил нет
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
@@ -195,7 +194,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient 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"));
@@ -214,7 +213,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
@@ -228,7 +227,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_EmptySourceAndTitle_JournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "inbox"));
@@ -241,7 +240,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_mid", Rules(
grade: new[] { "middle" },
budget: new BudgetRangeDto(1000, 3000, "USD"))));
@@ -263,7 +262,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromArchive_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
@@ -279,7 +278,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromTrash_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
@@ -296,7 +295,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -318,7 +317,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -331,7 +330,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_FromArchive_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -346,7 +345,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_CardMissing_ReturnsNull()
{
(CardsService service, _, _, FakeMlClient ml) = Create();
(CardsService service, _, _, TestMlClient ml) = Create();
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
@@ -358,7 +357,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
@@ -383,7 +382,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromArchive_NoPush()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
@@ -399,7 +398,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -414,7 +413,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -439,7 +438,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash,
sourceMsg: "Нужен Python-разработчик",
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
@@ -454,7 +453,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task DeleteForever_JournalRowsSurviveCardDeletion()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -480,7 +479,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ClearCol_Trash_ReturnsClearedCount()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash));
store.SeedCard(Card("l_2", KanbanColumns.Trash));
store.SeedCard(Card("l_3", KanbanColumns.Archive));
@@ -507,7 +506,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ClearCol_Board_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "b_py"));
@@ -531,7 +530,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_ById_OnlyThatCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true));
@@ -544,7 +543,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_ByCol_OnlyColumnCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore 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));
@@ -558,7 +557,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_All_WithoutParameters()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
@@ -571,7 +570,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
@@ -585,7 +584,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
@@ -618,7 +617,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Counts_ColumnsNewAndMlStats()
{
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox"));
store.SeedCard(Card("l_3", "b_py"));
@@ -652,7 +651,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
@@ -666,7 +665,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_DelegatesToStoreWithQueryAndLimit()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
@@ -681,7 +680,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore 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));
@@ -696,7 +695,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_LimitIsTwelve()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
for (int i = 0; i < 15; i++)
{
store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i));
@@ -711,12 +710,12 @@ public sealed class CardsServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
var store = new TestKanjStore();
var settings = new TestSettingsStore();
var ml = new TestMlClient();
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
}
// Доска с правилами (ContainerRulesDto) либо без них.
@@ -38,7 +38,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored()
{
(DialogsService service, TestTelegramStore store, FakeSettingsStore settings, _) = Create();
(DialogsService service, TestTelegramStore store, TestSettingsStore settings, _) = Create();
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые).
private static (DialogsService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway) Create()
private static (DialogsService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway) Create()
{
var store = new TestTelegramStore();
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway();
return (new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
return (new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
}
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
@@ -30,7 +30,7 @@ public sealed class DiscoveryEvaluatorTests
[Fact]
public async Task EvaluateMessage_MlSpam_NotFit()
{
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true);
(DiscoveryEvaluator evaluator, _, TestMlClient 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);
@@ -46,7 +46,7 @@ public sealed class DiscoveryEvaluatorTests
[Fact]
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict()
{
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
(DiscoveryEvaluator evaluator, _, TestMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает"));
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
@@ -168,14 +168,14 @@ public sealed class DiscoveryEvaluatorTests
=> new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null);
// Создаёт оценщик на фейках: флаги веток выставляются сценарием.
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
private static (DiscoveryEvaluator Evaluator, TestSettingsStore Settings, TestMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false");
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
var ml = new FakeMlClient();
var ml = new TestMlClient();
var ai = TestAiTools.New();
return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai);
return (new DiscoveryEvaluator(settings.Store, ml.Client, ai), settings, ml, ai);
}
// Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold).
@@ -419,7 +419,7 @@ public sealed class DiscoveryWorkerServiceTests
// Контекст теста: воркер на фейках + ссылки для проверок состояния.
private sealed record Fixture(
TestDiscoveryStore Store,
FakeSettingsStore Settings,
TestSettingsStore Settings,
TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer,
DiscoveryWorkerService Worker,
@@ -429,21 +429,21 @@ public sealed class DiscoveryWorkerServiceTests
private static Fixture Create()
{
var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new FakeMlClient();
var ml = new TestMlClient();
var ai = TestAiTools.New();
var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store.Store);
var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store.Store, settings);
var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings.Store);
var banGuard = new DiscoveryBanGuard(store.Store, settings.Store);
var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService(
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway.Gateway);
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway);
return new Fixture(store, settings, gateway, pacer, worker, tasks);
}
@@ -1,85 +0,0 @@
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;
}
}
@@ -39,9 +39,9 @@ public sealed class GrpcAiToolsTests
reply.Keywords.Add("python разработка");
reply.Keywords.Add("telegram каналы");
service.GenerateKeywordsReply = reply;
FakeSettingsStore settings = new();
TestSettingsStore settings = new();
ISecretCipher cipher = TestCiphers.New();
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, settings, cipher, limits);
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None);
@@ -66,7 +66,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.KeywordsUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
@@ -88,8 +88,8 @@ public sealed class GrpcAiToolsTests
Reason = "другая сфера",
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
};
FakeTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits);
TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New(), limits);
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
@@ -113,7 +113,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FitUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
await Assert.ThrowsAsync<AiUnavailableException>(
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
@@ -128,18 +128,19 @@ public sealed class GrpcAiToolsTests
// Возвращает: Экземпляр GrpcAiTools.
private static GrpcAiTools CreateTools(
int port,
FakeSettingsStore settings,
TestSettingsStore settings,
ISecretCipher cipher,
FakeTenantLimitStore? limits = null)
TestTenantLimitStore? limits = null)
{
limits ??= new TestTenantLimitStore();
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 TestTokenUsageEventStore().Store)),
new AiProviderConfigBuilder(settings.Store, cipher),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance);
}
}
@@ -110,10 +110,10 @@ public sealed class GrpcMlClientTests
Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 },
};
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlDecisions, "7");
settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new FakeMlLearningStore { LearningCount = 9 };
var learning = new TestMlLearningStore { LearningCount = 9 };
IMlClient client = CreateClient(port, settings, learning);
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
@@ -195,7 +195,7 @@ public sealed class GrpcMlClientTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0);
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -206,7 +206,7 @@ public sealed class GrpcMlClientTests
Assert.True(reset.Ok);
Assert.Null(reset.Error);
Assert.Equal(0, await learning.CountOutboxAsync(CancellationToken.None)); // очередь очищена
Assert.Equal(0, await learning.Store.CountOutboxAsync(CancellationToken.None)); // очередь очищена
Assert.Equal(1, service.ResetCalls);
_ = await client.StatusAsync(CancellationToken.None);
@@ -220,7 +220,7 @@ public sealed class GrpcMlClientTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -228,7 +228,7 @@ public sealed class GrpcMlClientTests
Assert.False(reset.Ok);
Assert.Equal("не удалось пересоздать файл модели", reset.Error);
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
});
}
@@ -238,7 +238,7 @@ public sealed class GrpcMlClientTests
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
service.ResetUnavailable = true;
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
IMlClient client = CreateClient(port, learning: learning);
@@ -246,7 +246,7 @@ public sealed class GrpcMlClientTests
Assert.False(reset.Ok);
Assert.Equal("ML-сервис недоступен", reset.Error);
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
});
}
@@ -256,7 +256,7 @@ public sealed class GrpcMlClientTests
{
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
{
var learning = new FakeMlLearningStore();
var learning = new TestMlLearningStore();
IMlClient client = CreateClient(port, learning: learning);
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
@@ -309,22 +309,24 @@ public sealed class GrpcMlClientTests
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
private static GrpcMlClient CreateClient(
int port,
FakeSettingsStore? settings = null,
FakeMlLearningStore? learning = null,
TestSettingsStore? settings = null,
TestMlLearningStore? learning = null,
MlStatusCache? cache = null)
{
settings ??= new TestSettingsStore();
learning ??= new TestMlLearningStore();
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(),
settings.Store,
learning.Store,
new MlGrpcConnection(options),
cache ?? new MlStatusCache(),
new TokenUsageRecorder(
settings ?? new FakeSettingsStore(),
new FakeTenantLimitStore(),
settings.Store,
new TestTenantLimitStore().Store,
tenantContext,
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcMlClient>.Instance);
@@ -13,7 +13,6 @@ 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;
@@ -144,11 +143,11 @@ public sealed class IntegrationsDiTests
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<LocalFieldsParser>();
services.AddScoped<AiClassifyContextBuilder>();
@@ -2,6 +2,7 @@ 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.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts;
@@ -13,10 +14,10 @@ public sealed class LocalAiClassifierTests
{
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
// Возвращает: Кортеж (KV-настройки, классификатор).
private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
private static (TestSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
{
var settings = new FakeSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings));
var settings = new TestSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings.Store));
return (settings, classifier);
}
@@ -140,7 +141,7 @@ public sealed class LocalAiClassifierTests
[Fact]
public async Task Classify_CustomHireMarkersOverride_AreUsed()
{
(FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
(TestSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
@@ -2,7 +2,6 @@ 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;
@@ -18,9 +17,9 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
AiParsedCardDto parsed = Parsed(
title: "Python-разработчик на бота",
budget: new AiBudgetDto(2000, 2000, "USD"),
@@ -38,16 +37,16 @@ public sealed class PipelineCardWriterTests
Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), card.Budget);
Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash));
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None));
Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None));
}
[Fact]
public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
CardDto card = await writer.CreateCardAsync(
Parsed(title: "Python-разработчик", board: "b_py"),
@@ -66,10 +65,10 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>(
() => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None));
@@ -77,19 +76,19 @@ public sealed class PipelineCardWriterTests
// Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null).
Assert.Empty(store.CardDtos);
Assert.Null(pipelineStore.DedupLeadId(hash));
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
}
// ─── Хелперы ──────────────────────────────────────────────────────────────────────────
// Создаёт контекст теста: фейки канбана, пайплайна и композитор поверх них.
// Создаёт контекст теста: подставки канбана, пайплайна и композитор поверх них.
// Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна).
private static (PipelineCardWriter Writer, FakeKanjStore Store, FakePipelineStore PipelineStore) Create()
private static (PipelineCardWriter Writer, TestKanjStore Store, TestPipelineStore 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);
var store = new TestKanjStore();
var pipelineStore = new TestPipelineStore();
var composer = new CardComposer(store.Store, new TestSettingsStore().Store);
return (new PipelineCardWriter(store.Store, pipelineStore.Store, composer), store, pipelineStore);
}
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
@@ -15,7 +15,6 @@ 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;
@@ -34,7 +33,7 @@ public sealed class PipelineWorkerGrpcAiTests
// Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service.
private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab";
// Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage).
// Guid того же тенанта — ключ строки лимита в TestTenantLimitStore (списание usage).
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
[Fact]
@@ -127,7 +126,7 @@ public sealed class PipelineWorkerGrpcAiTests
Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 },
};
var limits = new FakeTenantLimitStore();
var limits = new TestTenantLimitStore();
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$ в месяц"));
@@ -154,49 +153,49 @@ public sealed class PipelineWorkerGrpcAiTests
private sealed record Context(
PipelineWorkerService Worker,
FakePipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
FakeTenantLimitStore Limits);
TestPipelineStore PipelineStore,
TestKanjStore KanjStore,
TestSettingsStore Settings,
TestTenantLimitStore Limits);
private static Context CreateContext(
int port,
FakeTenantLimitStore? limits = null,
TestTenantLimitStore? 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);
var settings = new TestSettingsStore();
var pipelineStore = new TestPipelineStore();
var kanjStore = new TestKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
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();
limits ??= new TestTenantLimitStore();
var grpcClassifier = new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, TestCiphers.New()),
new AiClassifyContextBuilder(settings, kanjStore),
new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted
? new BudgetedAiClassifier(
grpcClassifier,
new LocalAiClassifier(fieldsParser),
limits,
limits.Store,
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 processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store));
var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore.Store, pipelineStore.Store, composer);
var worker = new PipelineWorkerService(
pipelineStore, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser);
pipelineStore.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier, processing, writer, fieldsParser);
return new Context(worker, pipelineStore, kanjStore, settings, limits);
}
@@ -6,7 +6,6 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Tests.Unit.Support;
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;
@@ -21,7 +20,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore store) = Create();
store.SeedCard(new CardDto
{
Id = "c_1",
@@ -44,7 +43,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_ToBoard_RoutesToDashboard()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore 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" } });
@@ -59,7 +58,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore 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);
@@ -79,10 +78,10 @@ public sealed class CardMoverTests
Assert.False(result.Exists);
}
private static (CardMover Mover, FakeKanjStore Store) Create()
private static (CardMover Mover, TestKanjStore Store) Create()
{
var store = new FakeKanjStore();
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new TestFileStorage().Storage);
var store = new TestKanjStore();
var cardsService = new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage);
return (new CardMover(cardsService), store);
}
}
@@ -40,8 +40,8 @@ public sealed class DiscoveryBanGuardTests
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
TestDiscoveryStore yesterdayStore = new(() => Now);
var settings = new FakeSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now);
var settings = new TestSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings.Store, () => Now);
for (int i = 0; i < 49; i++)
{
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
@@ -67,7 +67,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
await guard.NoteFloodAsync(CancellationToken.None);
@@ -79,7 +79,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task FloodYesterday_DoesNotBlockToday()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
@@ -89,7 +89,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task GlobalPaused_BlocksAutoJoins()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscPaused, "true");
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
@@ -99,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task CustomJoinLimit_AppliedWhenSettingStored()
{
(DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, TestDiscoveryStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscJoinLimit, "2");
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
@@ -109,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
}
// Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время).
private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
{
var store = new TestDiscoveryStore(() => Now);
var settings = new FakeSettingsStore();
var guard = new DiscoveryBanGuard(store.Store, settings, () => Now);
var settings = new TestSettingsStore();
var guard = new DiscoveryBanGuard(store.Store, settings.Store, () => Now);
return (guard, store, settings);
}
}
@@ -117,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Create_CustomThresholdAndSample_Respected()
{
(DiscoveryTasksService service, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, TestSettingsStore settings) = Create();
settings.Preload("discEvalThreshold", "60");
settings.Preload("discEvalSample", "20");
@@ -330,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests
}
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
{
var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore();
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings);
var settings = new TestSettingsStore();
var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
return (new DiscoveryTasksService(store.Store, planGuard, settings.Store), store, settings);
}
// Draft задачи с дефолтными полями (name/planJoins переопределяются).
@@ -1,614 +0,0 @@
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);
}
}
@@ -2,8 +2,8 @@ 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.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban;
@@ -24,11 +24,11 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildFilterPrompt_FillsDomainAndKeywords()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore 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);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -38,9 +38,9 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}"));
var builder = new AiClassifyContextBuilder(settings, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -52,12 +52,12 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore 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);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None);
@@ -69,8 +69,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
@@ -82,7 +82,7 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
kanj.SeedBoard(new ContainerDto
{
Id = "b_py",
@@ -113,7 +113,7 @@ public sealed class AiClassifyContextBuilderTests
Order = 1,
});
kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 });
var builder = new AiClassifyContextBuilder(settings, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None);
@@ -128,10 +128,10 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)");
SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)");
var builder = new AiClassifyContextBuilder(settings, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None);
@@ -144,8 +144,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string text = new string('д', 6000);
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
@@ -158,10 +158,10 @@ public sealed class AiClassifyContextBuilderTests
// Создаёт контекст: пустое KV + пустой канбан-фейк.
// kanj: Канбан-фейк (доски/журнал примеров).
// Возвращает: KV-хранилище тенанта.
private static FakeSettingsStore Context(out FakeKanjStore kanj)
private static TestSettingsStore Context(out TestKanjStore kanj)
{
kanj = new FakeKanjStore();
return new FakeSettingsStore();
kanj = new TestKanjStore();
return new TestSettingsStore();
}
// Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера).
@@ -170,12 +170,12 @@ public sealed class AiClassifyContextBuilderTests
// board: Колонка-назначение (доска).
// text: Исходный текст карточки (source_msg).
private static void SeedCardWithMove(
FakeKanjStore kanj,
TestKanjStore 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();
kanj.Store.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult();
}
}
@@ -3,7 +3,6 @@ using Deal.Tests.Unit.Support;
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;
@@ -19,7 +18,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот"));
CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None);
@@ -32,7 +31,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Set_Enabled_SetsReminderAndReturnsCard()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1));
long atMs = NowMs() + DayMs;
@@ -61,7 +60,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Set_StageNotHold_Allowed()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "work", title: "В работе"));
long atMs = NowMs() + DayMs;
@@ -76,7 +75,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Clear_WithReminder_ClearsItAndReturnsTrue()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
@@ -98,7 +97,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Clear_RemindersDisabled_StillClears()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
(CardsService service, TestKanjStore 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);
@@ -111,7 +110,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Snooze_MovesReminderToNowPlus24h()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
long beforeMs = NowMs();
@@ -136,7 +135,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Snooze_RemindersDisabled_StillSnoozes()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
(CardsService service, TestKanjStore 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);
@@ -149,7 +148,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
(CardsService service, TestKanjStore 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) });
@@ -167,7 +166,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task CheckDue_Enabled_ReturnsDueAndMarksFired()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore 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) });
@@ -181,21 +180,21 @@ public sealed class CardsServiceRemindersTests
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));
Assert.Empty(await store.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
}
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true)
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings) Create(bool remindersEnabled = true)
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
if (!remindersEnabled)
{
settings.Preload(SettingsKeys.RemindersEnabled, "false");
}
return (new CardsService(store, settings, new FakeMlClient(), new TestFileStorage().Storage), store, settings);
return (new CardsService(store.Store, settings.Store, new TestMlClient().Client, new TestFileStorage().Storage), store, settings);
}
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
@@ -1,79 +0,0 @@
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;
}
}
@@ -1,315 +0,0 @@
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,
};
}
@@ -5,7 +5,6 @@ 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;
@@ -19,15 +18,15 @@ public sealed class MlReviewServiceTests
private const string Dialog = "d_1";
private static MlReviewService Create(
FakePipelineStore pipeline,
FakeKanjStore kanj,
FakeMlClient ml,
TestPipelineStore pipeline,
TestKanjStore kanj,
TestMlClient ml,
out CardsService cards)
{
var settings = new FakeSettingsStore();
cards = new CardsService(kanj, settings, ml, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline));
return new MlReviewService(pipeline, kanj, cards, processing, ml);
var settings = new TestSettingsStore();
cards = new CardsService(kanj.Store, settings.Store, ml.Client, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store));
return new MlReviewService(pipeline.Store, kanj.Store, cards, processing, ml.Client);
}
private static QueueItemDto QueueRow(
@@ -73,12 +72,12 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts()
{
var pipeline = new FakePipelineStore();
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "из очереди"));
pipeline.SeedRejected(RejectedRow(102, "из отсева"));
var kanj = new FakeKanjStore();
var kanj = new TestKanjStore();
kanj.SeedCard(Card(103, "из карточки"));
MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, kanj, new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
@@ -94,10 +93,10 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Candidates_FiltersByDialog()
{
var pipeline = new FakePipelineStore();
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "нужный", Dialog));
pipeline.SeedQueue(QueueRow(201, "другой", "d_2"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
@@ -108,10 +107,10 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Candidates_NoDialog_ReturnsAllSources()
{
var pipeline = new FakePipelineStore();
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "a", "d_1"));
pipeline.SeedQueue(QueueRow(201, "b", "d_2"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(null, 10, CancellationToken.None);
@@ -121,13 +120,13 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Candidates_ClampsLimitToMax()
{
var pipeline = new FakePipelineStore();
var pipeline = new TestPipelineStore();
for (int i = 0; i < 5; i++)
{
pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}"));
}
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None);
@@ -137,10 +136,10 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_Skip_DoesNotLearn()
{
var pipeline = new FakePipelineStore();
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст"));
var ml = new FakeMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
var ml = new TestMlClient();
MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None);
@@ -154,10 +153,10 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_Spam_WithCard_TrashesAndLearns()
{
var kanj = new FakeKanjStore();
var kanj = new TestKanjStore();
kanj.SeedCard(Card(101, "спамный текст"));
var ml = new FakeMlClient();
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _);
var ml = new TestMlClient();
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
@@ -172,10 +171,10 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue()
{
var pipeline = new FakePipelineStore();
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "рекламный текст"));
var ml = new FakeMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
var ml = new TestMlClient();
MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
@@ -192,7 +191,7 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_Board_MovesCardAndLearns()
{
var kanj = new FakeKanjStore();
var kanj = new TestKanjStore();
kanj.SeedBoard(new ContainerDto
{
Id = "b_py",
@@ -201,8 +200,8 @@ public sealed class MlReviewServiceTests
Kind = ContainerKinds.Board,
});
kanj.SeedCard(Card(101, "python разработчик"));
var ml = new FakeMlClient();
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _);
var ml = new TestMlClient();
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None);
@@ -216,9 +215,9 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_UnknownBoard_ReturnsError()
{
var kanj = new FakeKanjStore();
var kanj = new TestKanjStore();
kanj.SeedCard(Card(101, "текст"));
MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _);
MlReviewService service = Create(new TestPipelineStore(), kanj, new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None);
@@ -230,9 +229,9 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_UnknownAction_ReturnsError()
{
var pipeline = new FakePipelineStore();
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None);
@@ -244,7 +243,7 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_MessageNotFound_ReturnsNull()
{
MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _);
MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None);
@@ -1,7 +1,7 @@
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.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban;
@@ -15,7 +15,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
(StorageTickService service, TestKanjStore 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 дн. — не кандидат
@@ -47,7 +47,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.AutoArchive, "false");
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
@@ -62,7 +62,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveAfterDays, "30");
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
@@ -78,7 +78,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveAfterDays, "1");
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
@@ -91,7 +91,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
(StorageTickService service, TestKanjStore store, _) = Create();
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20))));
@@ -109,7 +109,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
(StorageTickService service, TestKanjStore 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 дн.
@@ -129,7 +129,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveClearDays, "30");
DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
@@ -148,7 +148,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
(StorageTickService service, TestKanjStore 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))));
@@ -165,7 +165,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary()
{
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore 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))));
@@ -194,11 +194,11 @@ public sealed class StorageTickServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
private static (StorageTickService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create()
private static (StorageTickService Service, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
return (new StorageTickService(store, settings), store, settings);
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new StorageTickService(store.Store, settings.Store), store, settings);
}
// Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка).
@@ -215,13 +215,13 @@ public sealed class StorageTickServiceTests
private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds();
// Колонка карточки в фейке после тика.
private static string ColOf(FakeKanjStore store, string cardId) =>
private static string ColOf(TestKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).Col;
// Флаг is_new карточки после тика.
private static bool IsNewOf(FakeKanjStore store, string cardId) =>
private static bool IsNewOf(TestKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).IsNew;
private static IReadOnlyList<MatchHitDto> MatchHitsOf(FakeKanjStore store, string cardId) =>
private static IReadOnlyList<MatchHitDto> MatchHitsOf(TestKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).MatchHits;
}
@@ -1,7 +1,6 @@
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;
@@ -16,8 +15,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
PipelineIngestResultDto result = await service.EnqueueAsync(
Message(
@@ -48,8 +47,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
await service.EnqueueAsync(
Message("Сообщение без времени", receivedAt: default(DateTimeOffset)),
@@ -63,8 +62,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None);
@@ -76,8 +75,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None);
PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None);
@@ -93,8 +92,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
@@ -111,8 +110,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.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);
@@ -124,8 +123,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
PipelineIngestResultDto first = await service.EnqueueAsync(
Message("Первое", origin: "d_1", externalId: null), CancellationToken.None);
@@ -142,8 +141,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
string longText = new string('а', 7_000);
await service.EnqueueAsync(Message(longText), CancellationToken.None);
@@ -3,7 +3,6 @@ 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;
@@ -17,7 +16,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task RejectAsync_WhitespaceText_Noop()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None);
@@ -27,7 +26,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
await service.RejectAsync(new RejectRecord
{
@@ -53,7 +52,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore 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);
@@ -68,7 +67,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task QueueCountsAsync_CountsNewAndFiltered()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore 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));
@@ -83,7 +82,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
@@ -99,7 +98,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore 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));
@@ -116,7 +115,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore 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));
@@ -132,7 +131,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore 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));
@@ -152,7 +151,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore 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));
@@ -168,7 +167,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_RecordNotFound_ReturnsNull()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None);
@@ -180,7 +179,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_AlreadyReturned_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None);
@@ -195,7 +194,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_DuplicateSource_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -209,7 +208,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_EmptyText_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", text: " "));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -223,7 +222,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_1",
text: " Продвижение в каналах ",
@@ -270,7 +269,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_1",
text: "Короткое сообщение",
@@ -296,7 +295,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore 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);
@@ -309,7 +308,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
{
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_old",
text: "Старая запись без ссылки на исходное",
@@ -340,7 +339,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task DeleteAsync_RemovesSingleRecord()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
@@ -352,7 +351,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ClearAsync_ReturnsCountAndEmptiesRejects()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
store.SeedRejected(Rejected("r_3"));
@@ -368,7 +367,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore 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)));
@@ -381,12 +380,12 @@ public sealed class PipelineProcessingServiceTests
// ─── Помощники ──────────────────────────────────────────────────────────
// Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария.
private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create()
// Сервис на общих подставках: очередь/отсев и ML-клиент одного сценария.
private static (PipelineProcessingService Service, TestPipelineStore Store, TestMlClient Ml) Create()
{
FakePipelineStore store = new();
FakeMlClient ml = new();
PipelineProcessingService service = new(store, ml, new PipelineIngestService(store));
TestPipelineStore store = new();
TestMlClient ml = new();
PipelineProcessingService service = new(store.Store, ml.Client, new PipelineIngestService(store.Store));
return (service, store, ml);
}
@@ -1,63 +0,0 @@
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;
}
}
@@ -1,6 +1,7 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants;
@@ -14,8 +15,8 @@ public sealed class AuditServiceTests
[Fact]
public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields()
{
var store = new FakeAuditLogStore();
var service = new AuditService(store);
var store = new TestAuditLogStore();
var service = new AuditService(store.Store);
var actorId = Guid.NewGuid();
var tenantId = Guid.NewGuid();
var before = DateTimeOffset.UtcNow;
@@ -43,11 +44,11 @@ public sealed class AuditServiceTests
[Fact]
public async Task QueryAsync_ReturnsFilteredRecordsNewestFirst()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
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 service = new AuditService(store.Store);
var all = await service.QueryAsync(EmptyFilter(limit: 100), CancellationToken.None);
Assert.Equal(3, all.Count);
@@ -71,7 +72,7 @@ public sealed class AuditServiceTests
[Fact]
public async Task QueryAsync_AppliesAtRangeAndTenantFilters()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
var tenantId = Guid.NewGuid();
var outsideTenantId = Guid.NewGuid();
var from = DateTimeOffset.UtcNow.AddMinutes(-10);
@@ -79,7 +80,7 @@ public sealed class AuditServiceTests
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 service = new AuditService(store.Store);
var byRange = await service.QueryAsync(
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None);
@@ -94,13 +95,13 @@ public sealed class AuditServiceTests
[Fact]
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < 7; i++)
{
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow);
}
var service = new AuditService(store);
var service = new AuditService(store.Store);
var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 2);
Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None));
@@ -163,13 +164,13 @@ public sealed class AuditServiceTests
// Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров).
private static async Task AppendWithAtAsync(
FakeAuditLogStore store,
TestAuditLogStore store,
string eventType,
string actorType,
Guid? tenantId,
DateTimeOffset at)
{
await store.AppendAsync(new AuditRecordDto(
await store.Store.AppendAsync(new AuditRecordDto(
eventType,
actorType,
ActorId: null,
@@ -5,7 +5,7 @@ using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// Юнит-тесты AuthService на fake-хранилище и детерминированном «хэшере».
/// Юнит-тесты AuthService на подставке хранилища и детерминированном «хэшере».
/// </summary>
public sealed class AuthServiceTests
{
@@ -17,17 +17,17 @@ public sealed class AuthServiceTests
private static readonly Guid UserId = Guid.NewGuid();
private static readonly Guid UserTenantId = Guid.NewGuid();
private readonly FakeAuthStore _store;
private readonly TestAuthStore _store;
private readonly TestTenantStore _tenantStore;
private readonly AuthService _service;
public AuthServiceTests()
{
var passwordHasher = TestHashers.New();
_store = new FakeAuthStore();
_store = new TestAuthStore();
_tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
_store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
_service = new AuthService(_store, passwordHasher, _tenantStore.Repository);
_service = new AuthService(_store.Store, passwordHasher, _tenantStore.Repository);
}
[Fact]
@@ -133,7 +133,7 @@ public sealed class AuthServiceTests
var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
Assert.Null(user);
// Очистка «протухших» сессий вызвана и удалила сессию из fake-хранилища.
// Очистка «протухших» сессий вызвана и удалила сессию из хранилища.
Assert.Contains("delete-expired", _store.Calls);
Assert.Empty(_store.Sessions);
}
@@ -204,10 +204,10 @@ public sealed class AuthServiceTests
public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession()
{
var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore();
var suspendedStore = new TestAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository);
var suspendedService = new AuthService(suspendedStore.Store, passwordHasher, suspendedTenantStore.Repository);
var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None);
@@ -225,10 +225,10 @@ public sealed class AuthServiceTests
// Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает
// приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты).
var passwordHasher = TestHashers.New();
var suspendedStore = new FakeAuthStore();
var suspendedStore = new TestAuthStore();
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository);
var suspendedService = new AuthService(suspendedStore.Store, passwordHasher, suspendedTenantStore.Repository);
var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None);
@@ -266,12 +266,12 @@ public sealed class AuthServiceTests
public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser()
{
var passwordHasher = TestHashers.New();
var multiUserStore = new FakeAuthStore();
var multiUserStore = new TestAuthStore();
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.Repository);
var multiUserService = new AuthService(multiUserStore.Store, passwordHasher, _tenantStore.Repository);
// login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере).
var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None);
@@ -294,9 +294,9 @@ public sealed class AuthServiceTests
// Пользователь другого тенанта с тем же логином тоже не резолвится (не раскрываем существование логина).
var passwordHasher = TestHashers.New();
var crossStore = new FakeAuthStore();
var crossStore = new TestAuthStore();
crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p")));
var crossService = new AuthService(crossStore, passwordHasher, _tenantStore.Repository);
var crossService = new AuthService(crossStore.Store, passwordHasher, _tenantStore.Repository);
var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None);
Assert.False(cross.Ok);
@@ -1,56 +0,0 @@
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));
}
@@ -1,123 +0,0 @@
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;
}
}
@@ -1,81 +0,0 @@
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;
}
}
@@ -1,57 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITenantRepository"/> для unit-тестов
/// </summary>
public sealed class FakeTenantRepository : ITenantRepository
{
private readonly IReadOnlyList<TenantRecordDto> _tenants;
private readonly List<(int Offset, int Limit)> _pageRequests = [];
/// <summary>
/// Создаёт реестр с фиксированным списком тенантов.
/// </summary>
/// <param name="tenants">Тенанты реестра (как строки public.tenants).</param>
public FakeTenantRepository(params TenantRecordDto[] tenants)
{
_tenants = tenants.ToList();
}
/// <summary>
/// Запрошенные страницы (offset, limit) в порядке обращений.
/// </summary>
public IReadOnlyList<(int Offset, int Limit)> PageRequests => _pageRequests;
/// <inheritdoc />
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<TenantRecordDto>>(_tenants.ToList());
/// <inheritdoc />
public Task<IReadOnlyList<TenantRecordDto>> ListPageAsync(
int offset,
int limit,
CancellationToken ct)
{
_pageRequests.Add((offset, limit));
IReadOnlyList<TenantRecordDto> page = _tenants.Skip(offset).Take(limit).ToList();
return Task.FromResult(page);
}
/// <inheritdoc />
public Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct) =>
throw new NotSupportedException("FindByIdAsync не используется тестами реестра");
/// <inheritdoc />
public Task<bool> UpdateStatusAsync(
Guid id,
string status,
CancellationToken ct) =>
throw new NotSupportedException("UpdateStatusAsync не используется тестами реестра");
/// <inheritdoc />
public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) =>
throw new NotSupportedException("CreateAsync не используется тестами реестра");
}
@@ -1,68 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITenantRepository"/> с поддержкой создания тенантов.
/// </summary>
public sealed class FakeTenantStore : ITenantRepository
{
private readonly List<TenantRecordDto> _tenants;
/// <summary>
/// Создаёт хранилище, опционально с предзаполненным реестром тенантов.
/// </summary>
/// <param name="seed">Тенанты, которые уже есть в реестре (как строки public.tenants).</param>
public FakeTenantStore(params TenantRecordDto[] seed)
{
_tenants = seed.ToList();
}
/// <summary>
/// Записи реестра в порядке добавления.
/// </summary>
public IReadOnlyList<TenantRecordDto> Tenants => _tenants;
/// <inheritdoc />
public Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct) =>
Task.FromResult(_tenants.FirstOrDefault(tenant => tenant.Id == id));
/// <inheritdoc />
public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct)
{
_tenants.Add(tenant);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<TenantRecordDto>>(_tenants.ToList());
/// <inheritdoc />
public Task<IReadOnlyList<TenantRecordDto>> ListPageAsync(
int offset,
int limit,
CancellationToken ct)
{
IReadOnlyList<TenantRecordDto> page = _tenants.Skip(offset).Take(limit).ToList();
return Task.FromResult(page);
}
/// <inheritdoc />
public Task<bool> UpdateStatusAsync(
Guid id,
string status,
CancellationToken ct)
{
int index = _tenants.FindIndex(tenant => tenant.Id == id);
if (index < 0)
{
return Task.FromResult(false);
}
var current = _tenants[index];
_tenants[index] = current with { Status = status };
return Task.FromResult(true);
}
}
@@ -1,71 +0,0 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITokenUsageEventStore"/> для юнит/HTTP-тестов.
/// </summary>
public sealed class FakeTokenUsageEventStore : ITokenUsageEventStore
{
private readonly List<TokenUsageEventDto> _records = [];
/// <summary>
/// Записи хранилища в порядке добавления.
/// </summary>
public IReadOnlyList<TokenUsageEventDto> Records => _records;
/// <inheritdoc />
public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
{
_records.Add(record);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
{
IEnumerable<TokenUsageEventDto> filtered = _records.Where(record =>
(query.TenantId is null || record.TenantId == query.TenantId) &&
(string.IsNullOrWhiteSpace(query.Provider) || record.Provider == query.Provider) &&
(string.IsNullOrWhiteSpace(query.Model) || record.Model == query.Model) &&
(string.IsNullOrWhiteSpace(query.Kind) || record.Kind == query.Kind) &&
(query.From is null || record.At >= query.From.Value) &&
(query.To is null || record.At <= query.To.Value));
IReadOnlyList<TokenUsageAggregateDto> result = query.GroupBy switch
{
TokenUsageGroupBys.Day => GroupByDay(filtered),
TokenUsageGroupBys.Tenant => GroupByString(filtered, record => record.TenantId.ToString("D")),
TokenUsageGroupBys.Provider => GroupByString(filtered, record => record.Provider),
TokenUsageGroupBys.Model => GroupByString(filtered, record => record.Model),
_ => throw new ArgumentException($"Неизвестная группировка: '{query.GroupBy}'.", nameof(query)),
};
return Task.FromResult(result);
}
private static IReadOnlyList<TokenUsageAggregateDto> GroupByDay(IEnumerable<TokenUsageEventDto> source) =>
source
.GroupBy(record => record.At.UtcDateTime.Date)
.OrderBy(group => group.Key)
.Select(group => ToAggregate(group.Key.ToString("yyyy-MM-dd"), group))
.ToList();
private static IReadOnlyList<TokenUsageAggregateDto> GroupByString(IEnumerable<TokenUsageEventDto> source, Func<TokenUsageEventDto, string> keySelector) =>
source
.GroupBy(keySelector)
.Select(group => ToAggregate(group.Key, group))
.OrderByDescending(row => row.TotalTokens)
.ToList();
private static TokenUsageAggregateDto ToAggregate(string key, IEnumerable<TokenUsageEventDto> group)
{
List<TokenUsageEventDto> rows = group.ToList();
return new TokenUsageAggregateDto(
key,
rows.Sum(row => row.PromptTokens),
rows.Sum(row => row.CompletionTokens),
rows.Sum(row => row.TotalTokens),
rows.Count);
}
}
@@ -29,7 +29,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var passwordHasher = TestHashers.New();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher);
@@ -70,7 +70,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None);
@@ -87,7 +87,7 @@ public sealed class JoinFlowTests
var provisioner = new TestTenantProvisioner();
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -108,7 +108,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -126,7 +126,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -142,7 +142,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None);
@@ -159,7 +159,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -176,7 +176,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -189,7 +189,7 @@ public sealed class JoinFlowTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -203,7 +203,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None);
@@ -221,7 +221,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash"));
var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -248,7 +248,7 @@ public sealed class JoinFlowTests
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None);
@@ -267,7 +267,7 @@ public sealed class JoinFlowTests
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
var provisioner = new TestTenantProvisioner();
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
@@ -288,7 +288,7 @@ public sealed class JoinFlowTests
// Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -311,7 +311,7 @@ public sealed class JoinFlowTests
// Конкурентная активация «успела» до нашего CAS — приглашение уже активировано.
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var provisioner = new TestTenantProvisioner();
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
@@ -353,17 +353,17 @@ public sealed class JoinFlowTests
// ─── Хелперы ─────────────────────────────────────────────────────────
// Собирает JoinService на общих фейк-хранилищах (все состояния — свежие).
// Собирает JoinService на общих подставках хранилищ (все состояния — свежие).
private static JoinService NewService(
TestInviteStore inviteStore,
TestTenantStore tenantStore,
ITenantProvisioner provisioner,
FakeAuthStore authStore,
TestAuthStore authStore,
IPasswordHasher passwordHasher) =>
new(
new InvitesService(inviteStore.Store),
new TenantService(tenantStore.Repository, provisioner),
authStore,
authStore.Store,
passwordHasher,
tenantStore.Repository);
@@ -14,15 +14,15 @@ public sealed class OperatorAuthServiceTests
private static readonly Guid OperatorId = Guid.NewGuid();
private readonly FakeOperatorAuthStore _store;
private readonly TestOperatorAuthStore _store;
private readonly OperatorAuthService _service;
public OperatorAuthServiceTests()
{
var passwordHasher = TestHashers.New();
_store = new FakeOperatorAuthStore();
_store = new TestOperatorAuthStore();
_store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
_service = new OperatorAuthService(_store, passwordHasher);
_service = new OperatorAuthService(_store.Store, passwordHasher);
}
[Fact]
@@ -10,15 +10,15 @@ namespace Deal.Tests.Unit.Modules.Tenants;
/// </summary>
public sealed class OperatorBootstrapServiceTests
{
private readonly FakeOperatorAuthStore _store;
private readonly TestOperatorAuthStore _store;
private readonly IPasswordHasher _passwordHasher;
private readonly OperatorBootstrapService _service;
public OperatorBootstrapServiceTests()
{
_passwordHasher = TestHashers.New();
_store = new FakeOperatorAuthStore();
_service = new OperatorBootstrapService(_store, _passwordHasher);
_store = new TestOperatorAuthStore();
_service = new OperatorBootstrapService(_store.Store, _passwordHasher);
}
[Fact]
@@ -1,5 +1,6 @@
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Tenants;
@@ -14,7 +15,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_NoRecords_ReturnsEmpty()
{
SuspiciousActivityService service = Create(new FakeAuditLogStore());
SuspiciousActivityService service = Create(new TestAuditLogStore());
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
@@ -26,7 +27,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
{
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i);
@@ -46,7 +47,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++)
{
@@ -66,7 +67,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++)
{
SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i);
@@ -84,11 +85,11 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_ManyIpsPerActor_Triggers()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
Guid actor = Guid.NewGuid();
for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++)
{
await store.AppendAsync(
await store.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
@@ -113,10 +114,10 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++)
{
await store.AppendAsync(
await store.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant,
@@ -140,7 +141,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
@@ -160,7 +161,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
for (int i = 0; i < count; i++)
{
@@ -180,7 +181,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
{
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
@@ -195,7 +196,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
{
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
@@ -210,7 +211,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
{
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
@@ -225,7 +226,7 @@ public sealed class SuspiciousActivityServiceTests
[Fact]
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
{
var store = new FakeAuditLogStore();
var store = new TestAuditLogStore();
for (int i = 0; i < 50; i++)
{
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток
@@ -239,12 +240,12 @@ public sealed class SuspiciousActivityServiceTests
// Пишет запись «неудачный вход» с заданным временем.
private static void SeedFailed(
FakeAuditLogStore store,
TestAuditLogStore store,
string ip,
string login,
int minutesAgo)
{
store.AppendAsync(
store.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant,
@@ -258,12 +259,12 @@ public sealed class SuspiciousActivityServiceTests
// Пишет запись «успешный вход» с заданным временем.
private static void SeedSuccess(
FakeAuditLogStore store,
TestAuditLogStore store,
string ip,
string login,
int minutesAgo)
{
store.AppendAsync(
store.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginOk,
AuditActorTypes.Tenant,
@@ -275,5 +276,5 @@ public sealed class SuspiciousActivityServiceTests
CancellationToken.None);
}
private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now);
private static SuspiciousActivityService Create(TestAuditLogStore store) => new(store.Store, () => Now);
}
@@ -18,7 +18,7 @@ public sealed class TenantAdminServiceTests
{
var tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner();
var service = NewService(tenantStore, new FakeAuthStore(), provisioner);
var service = NewService(tenantStore, new TestAuthStore(), provisioner);
TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None);
@@ -42,7 +42,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword()
{
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var hasher = TestHashers.New();
var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher);
@@ -66,7 +66,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant()
{
var tenantStore = new TestTenantStore();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com"));
var service = NewService(tenantStore, authStore);
@@ -82,7 +82,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail()
{
var tenantStore = new TestTenantStore();
var service = NewService(tenantStore, new FakeAuthStore());
var service = NewService(tenantStore, new TestAuthStore());
TenantCreateResultDto result = await service.CreateAsync("Тенант", "not-an-email", CancellationToken.None);
@@ -95,7 +95,7 @@ public sealed class TenantAdminServiceTests
public async Task CreateAsync_WithEmptyName_ReturnsNameRequired()
{
var tenantStore = new TestTenantStore();
var service = NewService(tenantStore, new FakeAuthStore());
var service = NewService(tenantStore, new TestAuthStore());
TenantCreateResultDto result = await service.CreateAsync(" ", email: null, CancellationToken.None);
@@ -110,7 +110,7 @@ public sealed class TenantAdminServiceTests
var tenantStore = new TestTenantStore(
new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
new TenantRecordDto(SecondTenantId, "Второй", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
authStore.AddUser(NewUser(FirstTenantId, "a@example.com"));
authStore.AddUser(NewUser(FirstTenantId, "b@example.com"));
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
@@ -132,7 +132,7 @@ public sealed class TenantAdminServiceTests
public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers()
{
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
authStore.AddUser(NewUser(FirstTenantId, "a@example.com"));
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
var service = NewService(tenantStore, authStore);
@@ -150,7 +150,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task GetAsync_ForUnknownTenant_ReturnsNull()
{
var service = NewService(new TestTenantStore(), new FakeAuthStore());
var service = NewService(new TestTenantStore(), new TestAuthStore());
Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None));
}
@@ -159,7 +159,7 @@ public sealed class TenantAdminServiceTests
public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged()
{
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
var service = NewService(tenantStore, new FakeAuthStore());
var service = NewService(tenantStore, new TestAuthStore());
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -174,7 +174,7 @@ public sealed class TenantAdminServiceTests
public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange()
{
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
var service = NewService(tenantStore, new FakeAuthStore());
var service = NewService(tenantStore, new TestAuthStore());
// Повторный suspend приостановленного — Ok без изменения (аудит не дублируется, идемпотентность).
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
@@ -187,7 +187,7 @@ public sealed class TenantAdminServiceTests
[Fact]
public async Task ChangeStatusAsync_ForUnknownTenant_ReturnsNotFound()
{
var service = NewService(new TestTenantStore(), new FakeAuthStore());
var service = NewService(new TestTenantStore(), new TestAuthStore());
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None);
@@ -196,15 +196,15 @@ public sealed class TenantAdminServiceTests
Assert.Null(result.Tenant);
}
// Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore и хэшер-подставку).
// Сервис на подставках хранилищ (владелец-пользователи создаются через TestAuthStore и хэшер-подставку).
private static TenantAdminService NewService(
TestTenantStore tenantStore,
FakeAuthStore authStore,
TestAuthStore authStore,
TestTenantProvisioner? provisioner = null,
IPasswordHasher? hasher = null)
{
var tenantService = new TenantService(tenantStore.Repository, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner);
return new TenantAdminService(tenantStore.Repository, authStore, tenantService, hasher ?? TestHashers.New());
return new TenantAdminService(tenantStore.Repository, authStore.Store, tenantService, hasher ?? TestHashers.New());
}
// Пользователь тенанта (активный, без хэша — для чтения).
@@ -1,13 +1,15 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using NSubstitute;
namespace Deal.Tests.Unit.Modules.Tenants;
/// <summary>
/// In-memory реализация <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов
/// Подставка <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов:
/// сервисы получают NSubstitute-подставку (<see cref="Store"/>), тесты управляют сценарием через свойства
/// </summary>
public sealed class FakeTenantLimitStore : ITenantLimitStore
public sealed class TestTenantLimitStore
{
private sealed class Row
{
@@ -28,26 +30,46 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
private readonly Dictionary<Guid, Row> _rows = new();
private readonly TokenBudgetService _budgetService = new();
/// <summary>
/// Подставка порта лимитов (создаётся в конструкторе).
/// </summary>
public ITenantLimitStore Store { get; }
private readonly Func<DateTimeOffset> _utcNow;
/// <summary>
/// Создаёт фейк с системными часами и статусом тенанта active.
/// Создаёт подставку с системными часами и статусом тенанта active.
/// </summary>
public FakeTenantLimitStore()
public TestTenantLimitStore()
: this(() => DateTimeOffset.UtcNow, TenantStatuses.Active)
{
}
/// <summary>
/// Создаёт фейк с заданными часами и статусом тенанта
/// Создаёт подставку с заданными часами и статусом тенанта
/// </summary>
/// <param name="utcNow">Источник текущего времени (UTC).</param>
/// <param name="tenantStatus">Статус тенанта для всех строк (константа <see cref="TenantStatuses"/>).</param>
public FakeTenantLimitStore(Func<DateTimeOffset> utcNow, string tenantStatus = TenantStatuses.Active)
public TestTenantLimitStore(Func<DateTimeOffset> utcNow, string tenantStatus = TenantStatuses.Active)
{
ArgumentNullException.ThrowIfNull(utcNow);
_utcNow = utcNow;
TenantStatus = tenantStatus;
Store = Substitute.For<ITenantLimitStore>();
Store.GetOrCreateAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>(), Arg.Any<TokenLimitDefaults?>())
.Returns(ci => GetOrCreateAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1), ci.ArgAt<TokenLimitDefaults?>(2)));
Store.GetStateAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => GetStateAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
Store.AddUsageAsync(Arg.Any<Guid>(), Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns(ci => AddUsageAsync(ci.ArgAt<Guid>(0), ci.ArgAt<long>(1), ci.ArgAt<CancellationToken>(2)));
Store.UpdateBudgetAsync(Arg.Any<Guid>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => UpdateBudgetAsync(ci.ArgAt<Guid>(0), ci.ArgAt<long>(1), ci.ArgAt<string>(2), ci.ArgAt<CancellationToken>(3)));
Store.TryMarkWarnedAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => TryMarkWarnedAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
Store.TryMarkNotifiedExhaustedAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => TryMarkNotifiedExhaustedAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
Store.ResetExpiredPeriodsAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ResetExpiredPeriodsAsync(ci.ArgAt<DateTimeOffset>(0), ci.ArgAt<CancellationToken>(1)));
}
/// <summary>
@@ -56,12 +78,12 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
public string TenantStatus { get; set; }
/// <summary>
/// Тенанты, для которых <see cref="GetStateAsync"/> бросает исключение (устойчивость сборщиков)
/// Тенанты, для которых <see cref="GetStateAsync"/> подставки бросает исключение (устойчивость сборщиков)
/// </summary>
public HashSet<Guid> FailStateReads { get; } = new();
/// <summary>
/// Кладёт готовую строку лимита
/// Кладёт готовую строку лимита (в состояние подставки)
/// </summary>
/// <param name="tenantId">Тенант.</param>
/// <param name="budgetTokens">Бюджет периода.</param>
@@ -116,18 +138,16 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
/// <returns>True — строка существует.</returns>
public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId);
/// <inheritdoc />
public Task<TenantLimitDto> GetOrCreateAsync(
private Task<TenantLimitDto> GetOrCreateAsync(
Guid tenantId,
CancellationToken ct,
TokenLimitDefaults? defaults = null)
TokenLimitDefaults? defaults)
{
Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default);
return Task.FromResult(ToLimitDto(row, tenantId));
}
/// <inheritdoc />
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
private Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
{
if (FailStateReads.Contains(tenantId))
{
@@ -139,8 +159,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId));
}
/// <inheritdoc />
public Task<BudgetStateDto> AddUsageAsync(
private Task<BudgetStateDto> AddUsageAsync(
Guid tenantId,
long tokens,
CancellationToken ct)
@@ -155,8 +174,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId));
}
/// <inheritdoc />
public Task<BudgetStateDto> UpdateBudgetAsync(
private Task<BudgetStateDto> UpdateBudgetAsync(
Guid tenantId,
long budgetTokens,
string period,
@@ -170,8 +188,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(ToStateDto(row, tenantId));
}
/// <inheritdoc />
public Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
private Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
{
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
ResetIfPeriodExpired(row);
@@ -184,8 +201,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
private Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
{
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
ResetIfPeriodExpired(row);
@@ -198,8 +214,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
return Task.FromResult(true);
}
/// <inheritdoc />
public Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
private Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
{
int reset = 0;
foreach (Row row in _rows.Values)
@@ -3,6 +3,7 @@ using Deal.Api.Dtos;
using Deal.Api.Events;
using Deal.Api.Services;
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
@@ -10,8 +11,8 @@ using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using NSubstitute;
using Deal.Tests.Unit.Modules.Settings;
using Microsoft.Extensions.Logging.Abstractions;
@@ -28,9 +29,9 @@ public sealed class AdminTickOrchestratorTests
// Контекст теста: оркестратор на общих фейках + канал подписки.
private sealed record Context(
AdminTickOrchestrator Orchestrator,
FakePipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
TestPipelineStore PipelineStore,
TestKanjStore KanjStore,
TestSettingsStore Settings,
TestAiClassifier AiClassifier,
PipelinePumpGate PumpGate,
SseSubscription Subscription);
@@ -158,7 +159,7 @@ public sealed class AdminTickOrchestratorTests
Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString());
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
Assert.Empty(await ctx.KanjStore.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.Empty(await ctx.KanjStore.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
}
[Fact]
@@ -194,33 +195,39 @@ public sealed class AdminTickOrchestratorTests
// Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings,
// обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService);
// ML «не готов» (FakeMlClient.Predict не задан → сбой → «не уверен»).
// ML «не готов» (TestMlClient.Predict не задан → сбой → «не уверен»).
// withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump).
// withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки).
// Возвращает: Оркестратор, фейки и подписку канала тенанта.
private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false)
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
// При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст).
FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient();
TestPipelineStore store = new();
if (withThrowingQueueRead)
{
store.Store.ListAsync(Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<QueueItemDto>>(_ =>
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."));
}
var kanjStore = new TestKanjStore();
var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var ingest = new PipelineIngestService(store);
var processing = new PipelineProcessingService(store, mlClient, ingest);
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, store, composer);
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
var ingest = new PipelineIngestService(store.Store);
var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest);
var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
var worker = new PipelineWorkerService(
store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser);
store.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
var broker = new SseBroker();
var tickService = new StorageTickService(kanjStore, settings);
var tickService = new StorageTickService(kanjStore.Store, settings.Store);
var toastPublisher = new StorageToastPublisher(broker);
var pumpGate = new PipelinePumpGate();
FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore;
var cardsService = new CardsService(reminderStore, settings, mlClient, new TestFileStorage().Storage);
TestKanjStore reminderStore = withThrowingReminderCheck ? new TestKanjStore(throwOnDueReminders: true) : kanjStore;
var cardsService = new CardsService(reminderStore.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var orchestrator = new AdminTickOrchestrator(
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
NullLogger<AdminTickOrchestrator>.Instance);
@@ -229,16 +236,7 @@ public sealed class AdminTickOrchestratorTests
broker.Subscribe(TenantA));
}
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий
// «БД/схема недоступны» на проверке напоминаний — тик продолжается, reminders ответа пуст).
private sealed class ThrowingDueKanjStore : FakeKanjStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
}
}
// Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -261,19 +259,6 @@ public sealed class AdminTickOrchestratorTests
};
}
// Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump).
private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<QueueItemDto>> ListAsync(
string? status,
int limit,
CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).");
}
}
// Строка очереди (status new; сценарий перекрывает текст/время).
private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue(
id,
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Api.Hosting;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
@@ -27,7 +28,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact]
public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant()
{
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800);
limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000);
await using ServiceProvider provider = BuildProvider(
@@ -54,7 +55,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact]
public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet()
{
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -66,7 +67,7 @@ public sealed class BudgetAlertSchedulerTests
Assert.Empty(ReadToasts(subscriptionA));
// Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост).
Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None));
Assert.False(await limits.Store.TryMarkWarnedAsync(TenantA, CancellationToken.None));
}
[Fact]
@@ -74,7 +75,7 @@ public sealed class BudgetAlertSchedulerTests
{
// Строка «уже исчерпан, но ни один флаг не стоял» (сценарий: оператор уменьшил бюджет — флаги сброшены,
// расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу.
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -93,7 +94,7 @@ public sealed class BudgetAlertSchedulerTests
[Fact]
public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold()
{
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
@@ -101,7 +102,7 @@ public sealed class BudgetAlertSchedulerTests
SseSubscription subscriptionA = broker.Subscribe(TenantA);
BudgetAlertScheduler scheduler = CreateScheduler(provider);
await limits.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None);
await limits.Store.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None);
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA));
@@ -110,7 +111,7 @@ public sealed class BudgetAlertSchedulerTests
Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется
// Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен.
await limits.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None);
await limits.Store.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None);
await scheduler.RunCycleAsync(CancellationToken.None);
Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA));
@@ -124,11 +125,11 @@ public sealed class BudgetAlertSchedulerTests
// tenants: Фейк реестра тенантов (обход прохода).
// limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода).
// Возвращает: Провайдер с сервисами цикла.
private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits)
private static ServiceProvider BuildProvider(ITenantRepository tenants, TestTenantLimitStore limits)
{
var services = new ServiceCollection();
services.AddSingleton(tenants);
services.AddScoped<ITenantLimitStore>(_ => limits);
services.AddScoped<ITenantLimitStore>(_ => limits.Store);
services.AddSingleton<SseBroker>();
return services.BuildServiceProvider();
}
@@ -1,5 +1,6 @@
using System.Text.Json;
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
@@ -8,7 +9,6 @@ using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.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.Support;
@@ -21,9 +21,9 @@ public sealed class CardReclassifierTests
// Контекст теста: сервис поверх in-memory фейков.
private sealed record Context(
CardReclassifier Reclassifier,
FakeKanjStore Store,
FakeSettingsStore Settings,
FakeMlClient MlClient,
TestKanjStore Store,
TestSettingsStore Settings,
TestMlClient MlClient,
TestAiClassifier AiClassifier,
ReclassifyGate Gate);
@@ -31,16 +31,16 @@ public sealed class CardReclassifierTests
// Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки).
private static Context CreateContext()
{
var settings = new FakeSettingsStore();
var store = new FakeKanjStore();
var mlClient = new FakeMlClient();
var settings = new TestSettingsStore();
var store = new TestKanjStore();
var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier();
var fieldsParser = new LocalFieldsParser(settings);
var composer = new CardComposer(store, settings);
var cardsService = new CardsService(store, settings, mlClient, new TestFileStorage().Storage);
var fieldsParser = new LocalFieldsParser(settings.Store);
var composer = new CardComposer(store.Store, settings.Store);
var cardsService = new CardsService(store.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var gate = new ReclassifyGate();
var reclassifier = new CardReclassifier(
store, settings, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient, gate);
store.Store, settings.Store, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient.Client, gate);
return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate);
}
@@ -2,7 +2,6 @@ using System.Text;
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.Support;
@@ -16,7 +15,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1", updatedAtMs: 1));
byte[] content = Encoding.UTF8.GetBytes("данные-картинки");
using MemoryStream stream = new(content);
@@ -42,7 +41,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1"));
CardFileDto? entry = await service.AddFileAsync(
@@ -57,7 +56,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
CardFileDto? first = await service.AddFileAsync(
@@ -76,7 +75,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
CardFileDto? entry = await service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
@@ -89,7 +88,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1"));
CardFileDto? entry = await service.AddFileAsync(
@@ -105,7 +104,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
string rawName = "..\\файл\"отчёта v2.pdf";
@@ -123,7 +122,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_StreamPositionNotZero_StoresWholeContent()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла");
using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше
@@ -141,7 +140,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task GetEntry_ExistingFile_ReturnsEntryMeta()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000000_tz.pdf");
store.SeedCard(Card("c_1") with { Files = new[] { file } });
@@ -164,7 +163,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task GetEntry_FileNotInMetadata_ReturnsNull()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
@@ -177,7 +176,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf");
var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png");
store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } });
@@ -199,7 +198,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf");
store.SeedCard(Card("c_1") with { Files = new[] { file } });
@@ -214,7 +213,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } });
@@ -238,11 +237,11 @@ public sealed class CardsServiceFilesTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, TestFileStorage Storage) Create()
private static (CardsService Service, TestKanjStore Store, TestFileStorage Storage) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var storage = new TestFileStorage();
return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage.Storage), store, storage);
return (new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, storage.Storage), store, storage);
}
// Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст).
@@ -2,7 +2,6 @@ using System.Text.Json;
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.Support;
@@ -16,7 +15,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", updatedAtMs: 100));
store.SeedCard(Card("c_2", updatedAtMs: 300));
store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200));
@@ -29,7 +28,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task List_ByStage_ReturnsOnlyStageCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1"));
store.SeedCard(Card("c_2", stage: "work"));
@@ -53,7 +52,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
CardDto card = await service.CreateLocalCardAsync(
new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"),
@@ -102,7 +101,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None);
@@ -113,7 +112,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task TakeCard_MovesCardToPlannedKeepingFields()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card(
"c_1",
stage: "inbox",
@@ -150,7 +149,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", stage: "work", title: "Уже взята"));
CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None);
@@ -167,7 +166,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card(
"c_1",
title: "Старый заголовок",
@@ -198,7 +197,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_BudgetNull_ClearsBudget()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1)
with { Budget = new CardBudgetDto(1000, 2000, "USD") });
@@ -213,7 +212,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_StackNull_ClearsStack()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1)
with { Stack = new[] { "Python" } });
@@ -226,7 +225,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_UnknownKeys_AreIgnored()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Старый"));
CardDto? card = await service.PatchCardAsync(
@@ -242,7 +241,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_NullTextKey_IsIgnored()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1));
CardDto? card = await service.PatchCardAsync(
@@ -272,7 +271,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
long seededAt = 5_000;
CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt)
with
@@ -310,7 +309,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", stage: "planned"));
CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None);
@@ -337,7 +336,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", stage: "rejected"));
store.SeedCard(Card("c_2", stage: "rejected"));
store.SeedCard(Card("c_3", stage: "finished"));
@@ -363,7 +362,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1));
AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None);
@@ -381,7 +380,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddComment_TwoComments_AppendsPreservingOrder()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка")
with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } });
@@ -396,7 +395,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка"));
AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None);
@@ -411,7 +410,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddComment_CardMissing_ReturnsNullComments()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None);
@@ -424,7 +423,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } });
@@ -444,7 +443,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка"));
CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None);
@@ -458,7 +457,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка"));
CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None);
@@ -471,7 +470,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None);
@@ -483,7 +482,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
with
{
@@ -507,7 +506,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка")
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } });
@@ -520,7 +519,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task RemoveLink_CardMissing_ReturnsNullCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None);
@@ -531,12 +530,12 @@ public sealed class CardsServiceSelectedTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
var store = new TestKanjStore();
var settings = new TestSettingsStore();
var ml = new TestMlClient();
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
}
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
@@ -1,7 +1,6 @@
using System.Text.Json;
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.Support;
@@ -29,7 +28,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task Create_ExistingContainers_OrderIsMaxPlusOne()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
store.SeedBoard(Container("b_2", 2));
store.SeedBoard(Container("b_5", 5));
@@ -43,7 +42,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task Create_OrderEight_CyclesPaletteToFirst()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
store.SeedBoard(Container("b_7", 7));
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
@@ -157,7 +156,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task List_FillsCountsFromCards()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
store.SeedCard(new CardDto { Id = "c_1", Col = container.Id, IsNew = true });
store.SeedCard(new CardDto { Id = "c_2", Col = container.Id, IsNew = true });
@@ -283,16 +282,16 @@ public sealed class ContainersServiceTests
[Fact]
public async Task Reorder_AssignsOrdersInGivenOrder()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
store.SeedBoard(Container("b_1", 0));
store.SeedBoard(Container("b_2", 1));
store.SeedBoard(Container("b_3", 2));
await service.ReorderAsync(ContainerSpaces.Dashboard, ["b_3", "b_1", "b_2"], CancellationToken.None);
Assert.Equal(0, (await store.GetContainerAsync("b_3", CancellationToken.None))!.Order);
Assert.Equal(1, (await store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
Assert.Equal(2, (await store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
Assert.Equal(0, (await store.Store.GetContainerAsync("b_3", CancellationToken.None))!.Order);
Assert.Equal(1, (await store.Store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
Assert.Equal(2, (await store.Store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
}
// ─── Удаление контейнера ──────────────────────────────────────────────
@@ -300,7 +299,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task Delete_MovesCardsToInbox_ReturnsMovedCount()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
store.AddCard("c_1", container.Id);
store.AddCard("c_2", container.Id);
@@ -309,7 +308,7 @@ public sealed class ContainersServiceTests
int moved = await service.DeleteAsync(container.Id, CancellationToken.None);
Assert.Equal(2, moved);
Assert.Null(await store.GetContainerAsync(container.Id, CancellationToken.None)); // контейнер удалён
Assert.Null(await store.Store.GetContainerAsync(container.Id, CancellationToken.None)); // контейнер удалён
Assert.Contains(store.Cards, card => card.CardId == "c_1" && card.Col == "inbox" && card.IsNew);
Assert.Contains(store.Cards, card => card.CardId == "c_2" && card.Col == "inbox" && card.IsNew);
Assert.Contains(store.Cards, card => card.CardId == "c_inbox" && card.Col == "inbox"); // inbox не тронут
@@ -340,7 +339,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task GetColState_ReturnsStoredColumnsAsIs()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}""");
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
@@ -353,7 +352,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task PatchColState_NewColumn_WritesStateAndReturnsIt()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
ColumnStateDto result = await service.PatchColStateAsync(
"inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None);
@@ -368,7 +367,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"inbox":{"collapsed":true}}""");
ColumnStateDto result = await service.PatchColStateAsync(
@@ -380,7 +379,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task PatchColState_NullFields_KeepCurrentState()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}""");
ColumnStateDto result = await service.PatchColStateAsync(
@@ -392,7 +391,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"archive":{"collapsed":true}}""");
await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None);
@@ -408,7 +407,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task GetColState_CorruptJson_ReturnsEmpty()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", "{oops");
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
@@ -418,11 +417,11 @@ public sealed class ContainersServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (ContainersService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create()
private static (ContainersService Service, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
return (new ContainersService(store, settings), store, settings);
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new ContainersService(store.Store, settings.Store), store, settings);
}
// Патч только с нужными полями: остальные параметры null = «не менять».
@@ -3,8 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -19,7 +19,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_MockRates_ConvertsUsdBudgetToRub()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
store.SeedCard(Card("l_inbox", KanbanColumns.Inbox, from: 50, to: 200, cur: "USD"));
@@ -34,7 +34,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_TargetCurrencyFromSettings_ConvertsToConfiguredCurrency()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey)
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -49,7 +49,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_NoTargetCurrencySetting_DefaultsToRub()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -62,7 +62,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_UsdtBudget_TreatedAsUsdRate()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
var rates = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 };
settings.Preload(SettingsKeys.RatesCache, CacheJson(rates));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT"));
@@ -78,7 +78,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD"));
@@ -93,7 +93,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_ConversionOff_ReturnsZeroAndKeepsCardsUntouched()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ConversionOn, "false");
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -109,7 +109,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_ExcludesArchiveAndTrashCards()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
store.SeedCard(Card("l_archive", KanbanColumns.Archive, from: 100, to: 100, cur: "USD"));
@@ -128,7 +128,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 92.5 }));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with
{
@@ -147,7 +147,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_NoRatesCache_ReturnsZeroAndKeepsCardsUntouched()
{
(ConversionRecomputer service, FakeKanjStore store, _) = Create();
(ConversionRecomputer service, TestKanjStore store, _) = Create();
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
int updated = await service.RecomputeAsync(CancellationToken.None);
@@ -159,7 +159,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_CorruptedRatesCache_ReturnsZero()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, "{это не JSON");
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -174,7 +174,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task OnRatesChangedAsync_PerformsFullRecompute()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -189,7 +189,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_SecondRun_RecomputesSameValues()
{
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -203,11 +203,11 @@ public sealed class ConversionRecomputerTests
// ─── Хелперы ────────────────────────────────────────────────────────────
private static (ConversionRecomputer Service, FakeKanjStore Store, FakeSettingsStore Settings) Create()
private static (ConversionRecomputer Service, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
return (new ConversionRecomputer(settings, store), store, settings);
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new ConversionRecomputer(settings.Store, store.Store), store, settings);
}
// Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера).
@@ -237,7 +237,7 @@ public sealed class ConversionRecomputerTests
// Конверсия (Converted) карточки в фейке после пересчёта.
// store: Фейк-хранилище.
// cardId: Id карточки.
private static CardBudgetDto? ConvertedOf(FakeKanjStore store, string cardId) =>
private static CardBudgetDto? ConvertedOf(TestKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).Converted;
// Проверяет conv-поля карточки (From/To с допуском 0.001 и валюту).
@@ -247,7 +247,7 @@ public sealed class ConversionRecomputerTests
// convTo: Ожидаемая верхняя граница.
// convCur: Ожидаемая валюта конверсии.
private static void AssertConverted(
FakeKanjStore store,
TestKanjStore store,
string cardId,
double convFrom,
double convTo,
@@ -15,7 +15,6 @@ 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.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions;
@@ -52,12 +51,12 @@ public sealed class GrpcAiClassifierTests
Reason = "реклама",
Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 },
};
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None);
@@ -88,7 +87,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FilterUnavailable = true;
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>(
@@ -102,7 +101,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
string text = new string('а', 5000);
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.FilterAsync(text, CancellationToken.None);
@@ -131,7 +130,7 @@ public sealed class GrpcAiClassifierTests
""",
Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 },
};
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}."));
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке»."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
@@ -152,7 +151,7 @@ public sealed class GrpcAiClassifierTests
Order = 0,
});
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None);
@@ -191,9 +190,9 @@ public sealed class GrpcAiClassifierTests
Ok = false,
Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 },
};
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
FakeTenantLimitStore limits = new();
TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
// ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail).
@@ -212,7 +211,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyUnavailable = true;
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>(
@@ -226,7 +225,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiProvider, Json("anthropic"));
settings.Preload(
SettingsKeys.AiConfigs,
@@ -263,7 +262,7 @@ public sealed class GrpcAiClassifierTests
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
string text = new string('б', 6000);
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.ClassifyAsync(text, CancellationToken.None);
@@ -284,28 +283,29 @@ public sealed class GrpcAiClassifierTests
// Возвращает: Экземпляр GrpcAiClassifier.
private static GrpcAiClassifier CreateClassifier(
int port,
FakeSettingsStore settings,
TestSettingsStore settings,
ISecretCipher cipher,
FakeKanjStore kanjStore,
FakeTenantLimitStore? limits = null)
TestKanjStore kanjStore,
TestTenantLimitStore? limits = null)
{
limits ??= new TestTenantLimitStore();
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 GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, cipher),
new AiClassifyContextBuilder(settings, kanjStore),
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
new AiProviderConfigBuilder(settings.Store, cipher),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
}
// Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски).
// port: Порт хоста (не используется контекстом — единый вид хелперов).
// Возвращает: Кортеж фейков (настройки, шифр, канбан).
private static (FakeSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port)
=> (new FakeSettingsStore(), TestCiphers.New(), new FakeKanjStore());
private static (TestSettingsStore Settings, ISecretCipher Cipher, TestKanjStore Kanj) Context(int port)
=> (new TestSettingsStore(), TestCiphers.New(), new TestKanjStore());
// Сериализует значение настройки в JSON-строку (как пишет SettingsStore).
// value: Значение (строка/список).
@@ -313,7 +313,7 @@ public sealed class GrpcAiClassifierTests
private static string Json(object value) => JsonSerializer.Serialize(value);
private static async Task AssertUsageAsync(
FakeSettingsStore settings,
TestSettingsStore settings,
uint prompt,
uint completion,
uint total)
@@ -1,5 +1,6 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -12,7 +13,7 @@ public sealed class IncomingRulesTests
// Создаёт правила поверх in-memory KV-хранилища.
// store: Хранилище настроек тенанта.
// Возвращает: Экземпляр IncomingRules.
private static IncomingRules CreateRules(FakeSettingsStore store) => new(store);
private static IncomingRules CreateRules(TestSettingsStore store) => new(store.Store);
// Сериализует значение в JSON-строку строки settings (как пишет SettingsStore).
// value: Значение (строка/число/булево/список).
@@ -23,7 +24,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength()
{
IncomingRulesResult result = await CreateRules(new FakeSettingsStore())
IncomingRulesResult result = await CreateRules(new TestSettingsStore())
.CheckAsync("Короткий текст", CancellationToken.None);
Assert.False(result.Pass);
@@ -37,7 +38,7 @@ public sealed class IncomingRulesTests
public async Task CheckAsync_EmptyText_IsLengthFail()
{
// (text or "").strip() — пустой/пробельный текст всегда «короче minLen».
IncomingRulesResult result = await CreateRules(new FakeSettingsStore())
IncomingRulesResult result = await CreateRules(new TestSettingsStore())
.CheckAsync(" ", CancellationToken.None);
Assert.False(result.Pass);
@@ -47,7 +48,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MinLen, Json(10));
IncomingRules rules = CreateRules(store);
@@ -61,7 +62,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_LongCleanTextWithDefaults_Passes()
{
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()).CheckAsync(
IncomingRulesResult result = await CreateRules(new TestSettingsStore()).CheckAsync(
"Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу",
CancellationToken.None);
@@ -75,7 +76,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" }));
IncomingRules rules = CreateRules(store);
@@ -92,7 +93,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" }));
IncomingRules rules = CreateRules(store);
@@ -108,7 +109,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store);
@@ -125,7 +126,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_ResumeText_BlockResumesOff_Passes()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.BlockResumes, Json(false));
IncomingRules rules = CreateRules(store);
@@ -141,7 +142,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store);
@@ -158,7 +159,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store);
@@ -175,7 +176,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_WantedTypeFreelance_VacancyLikeText_BlockedAsType()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("freelance"));
IncomingRules rules = CreateRules(store);
@@ -194,7 +195,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_WantedTypeVacancy_OrderLikeText_BlockedAsType()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("vacancy"));
IncomingRules rules = CreateRules(store);
@@ -211,7 +212,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_WantedTypeFreelance_OrderText_Passes()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("freelance"));
IncomingRules rules = CreateRules(store);
@@ -229,7 +230,7 @@ public sealed class IncomingRulesTests
public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes()
{
// Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается.
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store);
@@ -37,10 +37,10 @@ public sealed class JoinEndpointHttpTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
var tenantStore = new TestTenantStore();
var provisioner = new TestTenantProvisioner();
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore,
@@ -56,7 +56,7 @@ public sealed class JoinEndpointHttpTests
Assert.False(response.Headers.Contains("Set-Cookie"));
});
// Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated.
// Пользователь создан (login=email, хэш через подставку-хэшер), тенант создан и провижинен один раз, инвайт activated.
StoredUserDto user = Assert.Single(authStore.Users);
Assert.Equal(Email, user.Login);
Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash);
@@ -80,10 +80,10 @@ public sealed class JoinEndpointHttpTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) =>
{
using (HttpResponseMessage first = await PostJsonAsync(
@@ -108,10 +108,10 @@ public sealed class JoinEndpointHttpTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
public async Task Join_WithUnknownCode_Returns400NotFound()
{
await RunAsync(
new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests
inviteStore.AddInvite(NewInvite(Email));
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -201,11 +201,11 @@ public sealed class JoinEndpointHttpTests
{
var inviteStore = new TestInviteStore();
inviteStore.AddInvite(NewInvite(Email));
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash"));
await RunAsync(
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -228,10 +228,10 @@ public sealed class JoinEndpointHttpTests
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
var provisioner = new TestTenantProvisioner();
var authStore = new FakeAuthStore();
var authStore = new TestAuthStore();
await RunAsync(
inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(),
inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(),
async (baseAddress, client) =>
{
using HttpResponseMessage response = await PostJsonAsync(
@@ -247,13 +247,13 @@ public sealed class JoinEndpointHttpTests
// ─── Хелперы ─────────────────────────────────────────────────────────
// Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий.
// Поднимает in-process Kestrel с /api/join на подставках хранилищ и прогоняет сценарий.
private static async Task RunAsync(
TestInviteStore inviteStore,
TestTenantStore tenantStore,
ITenantProvisioner provisioner,
FakeAuthStore authStore,
FakeAuditLogStore auditStore,
TestAuthStore authStore,
TestAuditLogStore auditStore,
Func<string, HttpClient, Task> scenario)
{
int port = TestPort.Allocate();
@@ -264,8 +264,8 @@ public sealed class JoinEndpointHttpTests
// Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает
// последняя регистрация (зеркало OperatorAuthHttpHost).
builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<IAuthStore>(authStore);
builder.Services.AddSingleton<IAuditLogStore>(auditStore);
builder.Services.AddSingleton<IAuthStore>(authStore.Store);
builder.Services.AddSingleton<IAuditLogStore>(auditStore.Store);
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository);
builder.Services.AddSingleton<ITenantProvisioner>(provisioner);
@@ -5,7 +5,7 @@ using Deal.Modules.Cards.Application.Sources;
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.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -17,18 +17,18 @@ public sealed class LocalColumnSuggesterTests
{
// Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля.
// Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер).
private static (FakeKanjStore Store, FakeSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
private static (TestKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var suggester = new LocalColumnSuggester(store, settings, new ContainersService(store, settings));
var store = new TestKanjStore();
var settings = new TestSettingsStore();
var suggester = new LocalColumnSuggester(store.Store, settings.Store, new ContainersService(store.Store, settings.Store));
return (store, settings, suggester);
}
private static void SeedInbox(
string id,
string text,
FakeKanjStore store,
TestKanjStore store,
long receivedAtMs = 0) =>
store.SeedCard(new CardDto
{
@@ -57,7 +57,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_TooFewCards_ReturnsTooFewCardsReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
for (int i = 1; i < SuggestHeuristics.MinInbox; i++)
{
SeedInbox($"l_{i}", "нужен python", store);
@@ -72,7 +72,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason()
{
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
settings.Preload(SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
SeedInbox("l_1", "нужен python", store);
@@ -87,7 +87,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_NoThemes_ReturnsNothingGroupedReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
for (int i = 0; i < uniqueWords.Length; i++)
{
@@ -103,7 +103,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_SimilarExistingBoard_ReturnsNothingGroupedReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
store.SeedBoard(new ContainerDto
{
Id = "b_python",
@@ -128,7 +128,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_Success_CreatesSuggestedBoardsAndPlacesCards()
{
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store);
@@ -169,7 +169,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_SuccessThenImmediateRepeat_BlockedByCooldown()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store);
@@ -191,7 +191,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestKeywords_Success_ReturnsRepeatedWordMarkers()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store);
@@ -209,7 +209,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestKeywords_TooFewCards_ReturnsReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_1", "нужен python", store);
SeedInbox("l_2", "нужен python", store);
@@ -223,7 +223,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestKeywords_TrashAndArchiveCards_AreNotCounted()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_1", "нужен python", store);
SeedInbox("l_2", "нужен vue", store);
store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } });
@@ -239,7 +239,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestKeywords_NoRepeatedMarkers_ReturnsReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
for (int i = 0; i < uniqueWords.Length; i++)
{
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
@@ -27,14 +28,17 @@ public sealed class LocalMlClientTests
// store: Хранилище настроек тенанта.
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
// Возвращает: Экземпляр LocalMlClient.
private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null)
=> new(store, learning ?? new FakeMlLearningStore());
private static LocalMlClient CreateClient(TestSettingsStore store, TestMlLearningStore? learning = null)
{
learning ??= new TestMlLearningStore();
return new(store.Store, learning.Store);
}
[Fact]
public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus()
{
LocalMlClient client = CreateClient(new FakeSettingsStore());
LocalMlClient client = CreateClient(new TestSettingsStore());
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
@@ -63,7 +67,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_StoredCounters_AreReflectedInStats()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7");
store.Preload(SettingsKeys.AiDecisions, "3");
LocalMlClient client = CreateClient(store);
@@ -78,7 +82,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_StoredMlEnabledFalse_Disables()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "false");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -89,7 +93,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_StoredMlEnabledTrue_Enables()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "true");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -102,7 +106,7 @@ public sealed class LocalMlClientTests
[InlineData("не json {{")]
public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson)
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, valueJson);
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -113,7 +117,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_DamagedCounter_DegradesToZero()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "не число");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -124,8 +128,8 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
{
var store = new FakeSettingsStore();
var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
var store = new TestSettingsStore();
var learning = new TestMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
LocalMlClient client = CreateClient(store, learning);
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None);
@@ -142,7 +146,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape()
{
LocalMlClient client = CreateClient(new FakeSettingsStore());
LocalMlClient client = CreateClient(new TestSettingsStore());
MlPredictResultDto result = await client.PredictAsync(
"Python backend на fastapi, бот в телеграм", CancellationToken.None);
@@ -161,8 +165,8 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
{
var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
@@ -181,8 +185,8 @@ public sealed class LocalMlClientTests
[InlineData("текст", " ")] // label из пробелов — пуста после trim
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
{
var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync(text, label, 1.0, CancellationToken.None);
@@ -192,8 +196,8 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
{
var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
string longText = new string('а', MaxLearningTextLength + 1000);
await client.PushAsync(longText, "spam", 1.0, CancellationToken.None);
@@ -206,8 +210,8 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
{
var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
string text = new string('x', MaxLearningTextLength - 1) + "🙂";
@@ -222,8 +226,8 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PushAsync_NegativeDelta_IsStoredAsIs()
{
var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
var learning = new TestMlLearningStore();
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
@@ -236,7 +240,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task ResetAsync_ReturnsOk()
{
MlResetResultDto result = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None);
MlResetResultDto result = await CreateClient(new TestSettingsStore()).ResetAsync(CancellationToken.None);
Assert.True(result.Ok);
Assert.Null(result.Error);
@@ -245,7 +249,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task ResetAsync_DoesNotTouchSettingsRows()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7");
LocalMlClient client = CreateClient(store);
@@ -259,9 +263,9 @@ public sealed class LocalMlClientTests
[Fact]
public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7");
var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи
var learning = new TestMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи
LocalMlClient client = CreateClient(store, learning);
await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None);
await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None);
@@ -278,7 +282,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "false");
store.Preload(SettingsKeys.MlDecisions, "7");
store.Preload(SettingsKeys.AiDecisions, "3");
@@ -316,7 +320,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task MlPredictResultDto_SerializesToPrototypeWireFormat()
{
MlPredictResultDto dto = await CreateClient(new FakeSettingsStore()).PredictAsync("текст", CancellationToken.None);
MlPredictResultDto dto = await CreateClient(new TestSettingsStore()).PredictAsync("текст", CancellationToken.None);
string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
@@ -329,7 +333,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task MlResetResultDto_Success_SerializesWithoutErrorKey()
{
MlResetResultDto dto = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None);
MlResetResultDto dto = await CreateClient(new TestSettingsStore()).ResetAsync(CancellationToken.None);
string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
@@ -35,7 +35,7 @@ public sealed class LoginAttemptEndpointHttpTests
public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail()
{
await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(),
new TestOperatorAuthStore(),
NewUserStore(),
async (baseAddress, _, users, _, _, _, _) =>
{
@@ -66,7 +66,7 @@ public sealed class LoginAttemptEndpointHttpTests
public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked()
{
await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(),
new TestOperatorAuthStore(),
NewUserStore(),
async (baseAddress, _, users, _, _, _, _) =>
{
@@ -108,7 +108,7 @@ public sealed class LoginAttemptEndpointHttpTests
using var capture = new SecurityEventCapture();
await OperatorAuthHttpHost.RunAsync(
new FakeOperatorAuthStore(),
new TestOperatorAuthStore(),
NewUserStore(),
async (baseAddress, _, _, _, _, _, _) =>
{
@@ -136,10 +136,10 @@ public sealed class LoginAttemptEndpointHttpTests
new() { Enabled = true };
// Фейк-хранилище пользователей с активным пользователем Login/Password.
private static FakeAuthStore NewUserStore()
private static TestAuthStore NewUserStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeAuthStore();
var store = new TestAuthStore();
store.AddUser(new StoredUserDto(
Guid.NewGuid(),
Login,
@@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -428,10 +429,10 @@ public sealed class MessageParseCoreTests
public async Task ParseAsync_ReadsMarkerOverridesFromSettings()
{
// Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер.
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" }));
LocalFieldsParser parser = new(store);
LocalFieldsParser parser = new(store.Store);
LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default);
LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default);
@@ -442,7 +443,7 @@ public sealed class MessageParseCoreTests
[Fact]
public async Task ParseAsync_NoOverrides_UsesDefaults()
{
LocalFieldsParser parser = new(new FakeSettingsStore());
LocalFieldsParser parser = new(new TestSettingsStore().Store);
LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default);
@@ -43,7 +43,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact]
public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant);
await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant);
@@ -83,10 +83,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact]
public async Task Suspicious_ReturnsFindingsForFailedLoginBurst()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
{
await auditStore.AppendAsync(
await auditStore.Store.AppendAsync(
new AuditRecordDto(
AuditEvents.TenantLoginFailed,
AuditActorTypes.Tenant,
@@ -179,7 +179,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact]
public async Task Activity_FiltersByActorIdAndPaginates()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
Guid actor = Guid.NewGuid();
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
@@ -222,7 +222,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
[Fact]
public async Task Audit_FiltersByActorIdAndOffset()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
Guid actor = Guid.NewGuid();
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
@@ -256,32 +256,32 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
new TenantRecordDto(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
return store;
}
private static FakeAuthStore NewUserStore()
private static TestAuthStore NewUserStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeAuthStore();
var store = new TestAuthStore();
store.AddUser(new StoredUserDto(
Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword)));
return store;
}
private static Task SeedAuditAsync(
FakeAuditLogStore store,
TestAuditLogStore store,
string eventType,
string actorType,
Guid? tenantId,
Guid? actorId = null,
int minutesAgo = 1) =>
store.AppendAsync(
store.Store.AppendAsync(
new AuditRecordDto(
eventType,
actorType,
@@ -39,7 +39,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -65,7 +65,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -91,7 +91,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -137,9 +137,9 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task Audit_ReturnsItemsNewestFirstWithTotal()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
// Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC).
await auditStore.AppendAsync(new AuditRecordDto(
await auditStore.Store.AppendAsync(new AuditRecordDto(
AuditEvents.TenantCreated,
AuditActorTypes.Operator,
ActorId: Guid.NewGuid(),
@@ -177,7 +177,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task Audit_FiltersByEventTypeAndActorType()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -218,7 +218,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task TenantLogout_WritesTenantLogoutAuditRecord()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -251,7 +251,7 @@ public sealed class OperatorAuditEndpointsHttpTests
[Fact]
public async Task OperatorLogout_WritesOperatorLogoutAuditRecord()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -279,10 +279,10 @@ public sealed class OperatorAuditEndpointsHttpTests
// ─── Хелперы ─────────────────────────────────────────────────────────
// Фейк-хранилище оператора с активным operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -292,10 +292,10 @@ public sealed class OperatorAuditEndpointsHttpTests
}
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
private static FakeAuthStore NewUserStore()
private static TestAuthStore NewUserStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeAuthStore();
var store = new TestAuthStore();
store.AddUser(new StoredUserDto(
Guid.NewGuid(),
UserLogin,
@@ -131,7 +131,7 @@ public sealed class OperatorAuthEndpointsHttpTests
[Fact]
public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401()
{
FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false);
TestOperatorAuthStore operatorStore = NewOperatorStore(active: false);
await OperatorAuthHttpHost.RunAsync(
operatorStore,
@@ -189,10 +189,10 @@ public sealed class OperatorAuthEndpointsHttpTests
}
// Фейк-хранилище оператора с активным (или нет) оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore(bool active = true)
private static TestOperatorAuthStore NewOperatorStore(bool active = true)
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -202,10 +202,10 @@ public sealed class OperatorAuthEndpointsHttpTests
}
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
private static FakeAuthStore NewUserStore()
private static TestAuthStore NewUserStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeAuthStore();
var store = new TestAuthStore();
store.AddUser(new StoredUserDto(
Guid.NewGuid(),
UserLogin,
@@ -33,17 +33,17 @@ internal static class OperatorAuthHttpHost
"Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3";
/// <summary>
/// Прогоняет сценарий против поднятого хоста с фейк-хранилищами оператора и пользователей.
/// Прогоняет сценарий против поднятого хоста с подставками хранилищ оператора и пользователей.
/// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, Task> scenario,
FakeAuditLogStore? auditStore = null) =>
TestOperatorAuthStore operatorStore,
TestAuthStore userStore,
Func<string, TestOperatorAuthStore, TestAuthStore, Task> scenario,
TestAuditLogStore? auditStore = null) =>
await RunCoreAsync(
operatorStore,
userStore,
@@ -56,16 +56,16 @@ internal static class OperatorAuthHttpHost
/// <summary>
/// Прогоняет сценарий
/// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и хранилища (без реестра тенантов).</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
TestOperatorAuthStore operatorStore,
TestAuthStore userStore,
Func<string, TestOperatorAuthStore, TestAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null) =>
await RunCoreAsync(
operatorStore,
@@ -79,17 +79,17 @@ internal static class OperatorAuthHttpHost
/// <summary>
/// Прогоняет сценарий
/// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, кроме лимитов.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
TestOperatorAuthStore operatorStore,
TestAuthStore userStore,
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null) =>
await RunCoreAsync(
@@ -104,22 +104,22 @@ internal static class OperatorAuthHttpHost
/// <summary>
/// Прогоняет сценарий
/// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, включая лимиты.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
/// <param name="limitStore">Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
/// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
/// <param name="limitStore">Подставка хранилища лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
/// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param>
public static async Task RunAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
TestOperatorAuthStore operatorStore,
TestAuthStore userStore,
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestInviteStore? inviteStore = null,
TestTenantStore? tenantStore = null,
FakeTenantLimitStore? limitStore = null,
TestTenantLimitStore? limitStore = null,
RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null) =>
await RunCoreAsync(
@@ -136,16 +136,16 @@ internal static class OperatorAuthHttpHost
/// <summary>
/// Прогоняет сценарий с глобальными настройками
/// </summary>
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
/// <param name="scenario">Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита.</param>
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="globalSettingsStore">Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
/// <param name="globalSettingsStore">Подставка глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
public static async Task RunWithGlobalSettingsAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
TestOperatorAuthStore operatorStore,
TestAuthStore userStore,
Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestGlobalSettingsStore? globalSettingsStore = null) =>
await RunCoreAsync(
operatorStore,
@@ -159,21 +159,21 @@ internal static class OperatorAuthHttpHost
// Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки).
private static async Task RunCoreAsync(
FakeOperatorAuthStore operatorStore,
FakeAuthStore userStore,
FakeAuditLogStore? auditStore,
TestOperatorAuthStore operatorStore,
TestAuthStore userStore,
TestAuditLogStore? auditStore,
TestInviteStore? inviteStore,
TestTenantStore? tenantStore,
FakeTenantLimitStore? limitStore,
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
TestTenantLimitStore? limitStore,
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
RateLimitOptions? rateLimitOptions = null,
TestTokenUsageEventStore? tokenUsageStore = null,
TestGlobalSettingsStore? globalSettingsStore = null)
{
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore();
TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore();
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
TestTenantLimitStore effectiveLimitStore = limitStore ?? new TestTenantLimitStore();
TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore();
TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore();
RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions();
@@ -186,12 +186,12 @@ internal static class OperatorAuthHttpHost
// последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests).
builder.Services.AddSingleton(TestHashers.New());
builder.Services.AddSingleton<ITenantContext, TenantContext>();
builder.Services.AddSingleton<IAuthStore>(userStore);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore);
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore);
builder.Services.AddSingleton<IAuthStore>(userStore.Store);
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore.Store);
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store);
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository);
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore);
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore.Store);
builder.Services.AddSingleton<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store);
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
@@ -92,14 +92,14 @@ public sealed class OperatorHealthEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario);
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -70,7 +70,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
[Fact]
public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
@@ -262,10 +262,10 @@ public sealed class OperatorInvitesEndpointsHttpTests
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -275,7 +275,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
}
// Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются).
private static FakeAuthStore NewUserStore() => new();
private static TestAuthStore NewUserStore() => new();
// HTTP-клиент с собственным CookieContainer (изоляция сценариев кук).
private static HttpClient CreateClient(string baseAddress) =>
@@ -120,7 +120,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact]
public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
const long newBudget = 20_000_000;
await RunAsync(
@@ -165,7 +165,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact]
public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, _, _, _, _, _) =>
@@ -195,7 +195,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
[Fact]
public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
const long budget = 25_000_000;
await RunAsync(
@@ -284,19 +284,19 @@ public sealed class OperatorLimitsEndpointsHttpTests
$"{baseAddress}/api/operator/tenants/{tenantId}/limit";
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, FakeAuditLogStore? auditStore = null)
private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null)
{
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
var tenantStore = new TestTenantStore(
new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow),
new TenantRecordDto(SecondTenantId, SecondTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow));
var limitStore = new FakeTenantLimitStore();
var limitStore = new TestTenantLimitStore();
limitStore.Preload(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true);
limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0);
return OperatorAuthHttpHost.RunAsync(
NewOperatorStore(),
new FakeAuthStore(),
new TestAuthStore(),
scenario,
auditStore,
tenantStore: tenantStore,
@@ -326,10 +326,10 @@ public sealed class OperatorLimitsEndpointsHttpTests
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -72,14 +72,14 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
}
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
private static Task RunAsync(TestTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore);
private static Task RunAsync(TestTenantStore tenantStore, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) =>
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario, tenantStore: tenantStore);
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -61,7 +61,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
[Fact]
public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, _, globalSettings, audit) =>
@@ -257,10 +257,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
$"{baseAddress}/api/operator/settings/telegram-keys";
// Прогоняет сценарий на хосте с фейком глобального хранилища и аудита.
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario, FakeAuditLogStore? auditStore = null) =>
private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario, TestAuditLogStore? auditStore = null) =>
OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
NewOperatorStore(),
new FakeAuthStore(),
new TestAuthStore(),
scenario,
auditStore);
@@ -273,10 +273,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -97,7 +97,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, _, tenantStore, _) =>
@@ -138,7 +138,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
const string ownerEmail = "owner@created.com";
await RunAsync(
@@ -237,7 +237,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, userStore, tenantStore, _) =>
@@ -300,7 +300,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
var tenantStore = new TestTenantStore(
new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow));
@@ -342,7 +342,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
[Fact]
public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit()
{
var auditStore = new FakeAuditLogStore();
var auditStore = new TestAuditLogStore();
await RunAsync(
async (baseAddress, operatorStore, userStore, _, _) =>
@@ -486,8 +486,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
private static Task RunAsync(
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, FakeAuditLogStore, Task> scenario,
FakeAuditLogStore? auditStore = null,
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
TestAuditLogStore? auditStore = null,
TestTenantStore? tenantStore = null)
{
// По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore).
@@ -503,8 +503,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
// Проверяет запись tenant_status_changed с заданным статусом и актором-оператором.
private static void AssertStatusChangedAudit(
FakeAuditLogStore auditStore,
FakeOperatorAuthStore operatorStore,
TestAuditLogStore auditStore,
TestOperatorAuthStore operatorStore,
string status)
{
// В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу.
@@ -569,10 +569,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
}
// Фейк-хранилище оператора с активным оператором operator/operator.
private static FakeOperatorAuthStore NewOperatorStore()
private static TestOperatorAuthStore NewOperatorStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeOperatorAuthStore();
var store = new TestOperatorAuthStore();
store.AddOperator(new StoredOperatorDto(
Guid.NewGuid(),
OperatorLogin,
@@ -582,10 +582,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
}
// Фейк-хранилище пользователей с одним пользователем в целевом тенанте.
private static FakeAuthStore NewUserStore()
private static TestAuthStore NewUserStore()
{
var passwordHasher = TestHashers.New();
var store = new FakeAuthStore();
var store = new TestAuthStore();
store.AddUser(new StoredUserDto(
Guid.NewGuid(),
TenantUserLogin,
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Api.Services;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Infrastructure.Data;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
@@ -17,7 +18,6 @@ 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.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
@@ -40,11 +40,11 @@ public sealed class PipelineWorkerSchedulerTests
// Контекст теста: планировщик на общих фейках + каналы подписок тенантов.
private sealed record Context(
PipelineWorkerScheduler Scheduler,
FakePipelineStore PipelineA,
FakeKanjStore KanjA,
TestPipelineStore PipelineA,
TestKanjStore KanjA,
SseSubscription SubscriptionA,
FakePipelineStore PipelineB,
FakeKanjStore KanjB,
TestPipelineStore PipelineB,
TestKanjStore KanjB,
SseSubscription SubscriptionB,
PipelinePumpGate PumpGate,
TenantContext TenantContext,
@@ -150,12 +150,12 @@ public sealed class PipelineWorkerSchedulerTests
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
var pipelineB = new FakePipelineStore();
var kanjA = new FakeKanjStore();
var kanjB = new FakeKanjStore();
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
var pipelineB = new TestPipelineStore();
var kanjA = new TestKanjStore();
var kanjB = new TestKanjStore();
var settingsA = new TestSettingsStore();
var settingsB = new TestSettingsStore();
var pumpGate = new PipelinePumpGate();
var aiClassifier = new TestAiClassifier
{
@@ -167,13 +167,13 @@ public sealed class PipelineWorkerSchedulerTests
services.AddSingleton<SseBroker>();
services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton(pumpGate);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB);
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA.Store : kanjB.Store);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA.Store : pipelineB.Store);
// Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddScoped<IncomingRules>();
services.AddScoped<LocalFieldsParser>();
@@ -266,18 +266,7 @@ public sealed class PipelineWorkerSchedulerTests
return cardIds;
}
// Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump).
private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<QueueItemDto>> ListAsync(
string? status,
int limit,
CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).");
}
}
// Хранилище со сбоем чтения очереди задаётся флагом throwOnList в TestPipelineStore.
// Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump).
private sealed class ListLogger : ILogger<PipelineWorkerScheduler>
@@ -1,12 +1,12 @@
using System.Text.Json;
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
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.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
@@ -24,29 +24,29 @@ public sealed class PipelineWorkerServiceTests
// Контекст теста: воркер поверх in-memory фейков хранилищ/портов.
private sealed record Context(
PipelineWorkerService Worker,
FakePipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
FakeMlClient MlClient,
TestPipelineStore PipelineStore,
TestKanjStore KanjStore,
TestSettingsStore Settings,
TestMlClient MlClient,
TestAiClassifier AiClassifier);
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
// pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore).
// Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов).
private static Context CreateContext(FakePipelineStore? pipelineStore = null)
private static Context CreateContext(TestPipelineStore? pipelineStore = null)
{
var settings = new FakeSettingsStore();
var store = pipelineStore ?? new FakePipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
var settings = new TestSettingsStore();
var store = pipelineStore ?? new TestPipelineStore();
var kanjStore = new TestKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store));
var composer = new CardComposer(kanjStore, settings);
var writer = new PipelineCardWriter(kanjStore, store, composer);
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store));
var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
var worker = new PipelineWorkerService(
store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser);
store.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier);
}
@@ -54,38 +54,7 @@ public sealed class PipelineWorkerServiceTests
// видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел
// заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается.
// claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»).
private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore
{
/// <summary>
/// Сколько раз вызван ClaimAsync
/// </summary>
public int ClaimCalls { get; private set; }
/// <summary>
/// Сколько раз вызван DeleteClaimAsync
/// </summary>
public int DeleteClaimCalls { get; private set; }
/// <inheritdoc />
public override Task<bool> ExistsAsync(string hash, CancellationToken ct)
{
return Task.FromResult(false); // параллельный проход ещё не вставил строку на момент проверки
}
/// <inheritdoc />
public override Task<bool> ClaimAsync(string hash, CancellationToken ct)
{
ClaimCalls++;
return Task.FromResult(claimResult); // ON CONFLICT DO NOTHING: вставил другой проход → false
}
/// <inheritdoc />
public override Task DeleteClaimAsync(string hash, CancellationToken ct)
{
DeleteClaimCalls++;
return Task.CompletedTask;
}
}
// Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore).
// value: Значение (число/булево/строка/список).
@@ -284,13 +253,13 @@ public sealed class PipelineWorkerServiceTests
[Fact]
public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard()
{
Context ctx = CreateContext(new RacingClaimPipelineStore());
Context ctx = CreateContext(new TestPipelineStore(claimRace: true));
const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц";
ctx.PipelineStore.SeedQueue(QueueRow("p_1", text));
PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default);
RacingClaimPipelineStore store = Assert.IsType<RacingClaimPipelineStore>(ctx.PipelineStore);
TestPipelineStore store = ctx.PipelineStore;
Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
Assert.Equal("dup", rejected.Stage);
@@ -377,7 +346,7 @@ public sealed class PipelineWorkerServiceTests
Assert.Empty(ctx.KanjStore.CardDtos);
Assert.Equal(1, result.NoBudget);
Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L11431147)
Assert.False(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default));
Assert.False(await ctx.PipelineStore.Store.ExistsAsync(DedupHasher.Hash(text), default));
Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions));
}
@@ -615,7 +584,7 @@ public sealed class PipelineWorkerServiceTests
QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue);
Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился
Assert.True(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте
Assert.True(await ctx.PipelineStore.Store.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте
Assert.Empty(ctx.PipelineStore.Rejected);
Assert.Empty(ctx.KanjStore.CardDtos);
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
using NSubstitute;
@@ -14,7 +15,7 @@ public sealed class RatesServiceTests
{
private const long FixedTimestampMs = 1_752_000_000_000;
private readonly FakeSettingsStore _store = new();
private readonly TestSettingsStore _store = new();
// ─── GetAsync: кэш и дефолт ─────────────────────────────────────────────
@@ -418,7 +419,7 @@ public sealed class RatesServiceTests
private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
{
return new RatesService(_store, source, listeners);
return new RatesService(_store.Store, source, listeners);
}
// JSON значения ratesCache с одним курсом USD и заданным временем/источником.
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
using NSubstitute;
@@ -12,13 +13,13 @@ namespace Deal.Tests.Unit.Support;
/// </summary>
public sealed class SettingsServiceTests
{
private readonly FakeSettingsStore _store = new();
private readonly TestSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly SettingsService _service;
public SettingsServiceTests()
{
_service = new SettingsService(_store, _cipher, Array.Empty<IRatesChangedListener>());
_service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>());
}
// ─── GET: снимок ─────────────────────────────────────────────────────────
@@ -592,7 +593,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, _cipher, new[] { listener });
PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None);
@@ -605,7 +606,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
@@ -617,7 +618,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None);
@@ -628,7 +629,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener });
SettingsService local = new(_store.Store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None);
@@ -46,12 +46,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => store),
services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel =>
{
PushSourceReply reply = await PushAsync(
@@ -80,12 +80,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => store),
services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel =>
{
PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken);
@@ -104,12 +104,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => store),
services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel =>
{
PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken);
@@ -126,12 +126,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => store),
services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel =>
{
PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken);
@@ -150,10 +150,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_WithoutToken_IsUnauthenticated()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
{
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null));
@@ -167,10 +167,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_WithWrongToken_IsUnauthenticated()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
{
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token"));
@@ -184,7 +184,7 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_UnsetEnvToken_FailsClosed()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await TelegramIngressTestHost.RunAsync(
@@ -192,7 +192,7 @@ public sealed class SourceIngressGrpcServiceTests
configureServices: services =>
{
services.AddSingleton<ITenantRepository>(registry);
services.AddScoped<IPipelineStore>(_ => store);
services.AddScoped<IPipelineStore>(_ => store.Store);
},
scenario: async channel =>
{
@@ -208,10 +208,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
{
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken));
@@ -11,12 +11,12 @@ using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
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.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
@@ -42,9 +42,9 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel()
{
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new FakeSettingsStore();
TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
@@ -70,10 +70,10 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges()
{
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
var storeB = new FakeKanjStore();
TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
var storeB = new TestKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
@@ -99,16 +99,16 @@ public sealed class StorageTickSchedulerTests
public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants()
{
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
var settingsByTenant = new Dictionary<Guid, ISettingsStore>
{
[TenantA] = new ThrowingSettingsStore(),
[TenantB] = new FakeSettingsStore(),
[TenantB] = new TestSettingsStore().Store,
};
var storesByTenant = new Dictionary<Guid, FakeKanjStore>
var storesByTenant = new Dictionary<Guid, TestKanjStore>
{
[TenantA] = new FakeKanjStore(),
[TenantA] = new TestKanjStore(),
[TenantB] = storeB,
};
await using ServiceProvider provider = BuildProvider(
@@ -137,7 +137,7 @@ public sealed class StorageTickSchedulerTests
await using ServiceProvider provider = BuildProvider(
new ThrowingTenantRepository(),
tenantContext,
new Dictionary<Guid, FakeKanjStore>(),
new Dictionary<Guid, TestKanjStore>(),
new Dictionary<Guid, ISettingsStore>());
StorageTickScheduler scheduler = CreateScheduler(provider);
@@ -150,19 +150,19 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast()
{
var kanjStore = new FakeKanjStore();
var settings = new FakeSettingsStore();
var kanjStore = new TestKanjStore();
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
var pipelineStoreA = new FakePipelineStore();
var pipelineStoreA = new TestPipelineStore();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings },
new Dictionary<Guid, FakePipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() });
new Dictionary<Guid, TestKanjStore> { [TenantA] = kanjStore, [TenantB] = new TestKanjStore() },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() });
SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -181,18 +181,18 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired()
{
var cardStoreA = new FakeKanjStore();
var cardStoreA = new TestKanjStore();
cardStoreA.SeedCard(HoldCard("c_a_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000));
cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000));
cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000));
var cardStoreB = new FakeKanjStore();
var cardStoreB = new TestKanjStore();
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() },
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new(), [TenantB] = new() });
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -210,24 +210,24 @@ public sealed class StorageTickSchedulerTests
Assert.False(subscriptionB.Events.TryRead(out _));
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
Assert.Empty(await cardStoreA.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.Empty(await cardStoreA.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.False(tenantContext.HasTenant);
}
[Fact]
public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue()
{
var cardStoreA = new FakeKanjStore();
var cardStoreA = new TestKanjStore();
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new FakeSettingsStore();
var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA },
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new() });
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA.Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -245,15 +245,15 @@ public sealed class StorageTickSchedulerTests
{
// У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
var cardStoreA = new ThrowingDueKanjStore();
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() },
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new(), [TenantB] = new() });
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -281,15 +281,15 @@ public sealed class StorageTickSchedulerTests
private static ServiceProvider BuildProvider(
TestTenantRepository tenants,
TenantContext tenantContext,
FakeKanjStore storeA,
FakeKanjStore storeB,
FakeSettingsStore settings)
TestKanjStore storeA,
TestKanjStore storeB,
TestSettingsStore settings)
{
return BuildProvider(
tenants.Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings });
new Dictionary<Guid, TestKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store });
}
// Строит DI-провайдер теста по явным картам тенант → хранилище/настройки.
@@ -302,14 +302,14 @@ public sealed class StorageTickSchedulerTests
private static ServiceProvider BuildProvider(
ITenantRepository tenants,
TenantContext tenantContext,
Dictionary<Guid, FakeKanjStore> storesByTenant,
Dictionary<Guid, TestKanjStore> storesByTenant,
Dictionary<Guid, ISettingsStore> settingsByTenant,
Dictionary<Guid, FakePipelineStore>? pipelineStoresByTenant = null)
Dictionary<Guid, TestPipelineStore>? pipelineStoresByTenant = null)
{
pipelineStoresByTenant ??= new Dictionary<Guid, FakePipelineStore>
pipelineStoresByTenant ??= new Dictionary<Guid, TestPipelineStore>
{
[TenantA] = new FakePipelineStore(),
[TenantB] = new FakePipelineStore(),
[TenantA] = new TestPipelineStore(),
[TenantB] = new TestPipelineStore(),
};
var services = new ServiceCollection();
@@ -319,10 +319,10 @@ public sealed class StorageTickSchedulerTests
services.AddSingleton(tenants);
// Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк
// выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant.
services.AddScoped<ICardStore>(provider => storesByTenant[TenantOf(provider)]);
services.AddScoped<ICardStore>(provider => storesByTenant[TenantOf(provider)].Store);
services.AddScoped<ISettingsStore>(provider => settingsByTenant[TenantOf(provider)]);
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)]);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)].Store);
services.AddSingleton<IMlClient>(new TestMlClient().Client);
services.AddSingleton<IFileStorage>(new TestFileStorage().Storage);
services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
@@ -359,17 +359,7 @@ public sealed class StorageTickSchedulerTests
private static TenantRecordDto Tenant(Guid id) =>
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий
// «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в
// AdminTickOrchestratorTests).
private sealed class ThrowingDueKanjStore : FakeKanjStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
}
}
// Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -394,9 +384,9 @@ public sealed class StorageTickSchedulerTests
// Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика.
// cardId: Id карточки.
// Возвращает: Фейк-хранилище с одной старой карточкой inbox.
private static FakeKanjStore StoreWithExpiredInboxCard(string cardId)
private static TestKanjStore StoreWithExpiredInboxCard(string cardId)
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge)));
return store;
}
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Grpc.Telegram;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
@@ -61,7 +62,7 @@ public sealed class TelegramIngressServiceTests
public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror()
{
var registry = TestRegistry.With(Tenant(TenantA));
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
var dialogs = new TestTelegramStore();
@@ -69,7 +70,7 @@ public sealed class TelegramIngressServiceTests
registry,
services =>
{
services.AddScoped<ISettingsStore>(_ => settings);
services.AddScoped<ISettingsStore>(_ => settings.Store);
services.AddScoped<ITelegramStore>(_ => dialogs.Store);
},
async channel =>
@@ -91,7 +92,7 @@ public sealed class TelegramIngressServiceTests
[Fact]
public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts()
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var registry = TestRegistry.With(Tenant(TenantA));
var broker = new SseBroker();
@@ -100,7 +101,7 @@ public sealed class TelegramIngressServiceTests
services =>
{
services.AddSingleton<SseBroker>(broker);
services.AddScoped<ISettingsStore>(_ => settings);
services.AddScoped<ISettingsStore>(_ => settings.Store);
},
async channel =>
{
@@ -138,12 +139,12 @@ public sealed class TelegramIngressServiceTests
[Fact]
public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError()
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<ISettingsStore>(_ => settings),
services => services.AddScoped<ISettingsStore>(_ => settings.Store),
async channel =>
{
var client = new IngressService.IngressServiceClient(channel);
@@ -261,7 +262,7 @@ public sealed class TelegramIngressServiceTests
// phase: Ожидаемая фаза.
// connected: Ожидаемый флаг connected.
private static void AssertStatusStored(
FakeSettingsStore settings,
TestSettingsStore settings,
string phase,
bool connected)
{
@@ -99,7 +99,7 @@ internal static class TelegramIngressTestHost
.AddCheck("ready", () => HealthCheckResult.Healthy("хост готов"));
}
builder.Services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
builder.Services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
builder.Services.AddScoped<ITelegramStore>(_ => new TestTelegramStore().Store);
builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
builder.Services.AddTelegramModule();
@@ -71,7 +71,7 @@ public sealed class TelegramSourceIngestObserverTests
var store = new TestTelegramStore();
var dialogs = new DialogsService(
store.Store,
new FakeSettingsStore(),
new TestSettingsStore().Store,
new TestTelegramGateway().Gateway,
NullLogger<DialogsService>.Instance);
var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.Instance);
@@ -0,0 +1,59 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IAuditLogStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты проверяют записи через <see cref="Records"/>.
/// </summary>
public sealed class TestAuditLogStore
{
private readonly List<AuditRecordDto> _records = [];
/// <summary>
/// Подставка порта аудита (создаётся в конструкторе).
/// </summary>
public IAuditLogStore Store { get; }
/// <summary>
/// Записи хранилища в порядке добавления.
/// </summary>
public IReadOnlyList<AuditRecordDto> Records => _records;
/// <summary>
/// Создаёт подставку с пустым журналом.
/// </summary>
public TestAuditLogStore()
{
Store = Substitute.For<IAuditLogStore>();
Store.When(s => s.AppendAsync(Arg.Any<AuditRecordDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _records.Add(ci.Arg<AuditRecordDto>() with { Id = _records.Count + 1 }));
Store.QueryAsync(Arg.Any<AuditQueryDto>(), Arg.Any<CancellationToken>())
.Returns(ci => Query(ci.Arg<AuditQueryDto>()));
Store.CountAsync(Arg.Any<AuditQueryDto>(), Arg.Any<CancellationToken>())
.Returns(ci => ApplyFilters(ci.Arg<AuditQueryDto>()).Count());
Store.PurgeOlderThanAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => _records.RemoveAll(record => record.At < ci.ArgAt<DateTimeOffset>(0)));
}
private IReadOnlyList<AuditRecordDto> Query(AuditQueryDto filter)
{
return ApplyFilters(filter)
.OrderByDescending(r => r.At)
.Skip(Math.Max(0, filter.Offset))
.Take(Math.Max(1, Math.Min(AuditService.MaxQueryLimit, filter.Limit)))
.ToList();
}
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,127 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IAuthStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют пользователей и сессии через
/// <see cref="AddUser"/>, <see cref="AddSession"/>, <see cref="Users"/>, <see cref="Sessions"/>,
/// журнал мутирующих вызовов — <see cref="Calls"/>.
/// </summary>
public sealed class TestAuthStore
{
private readonly List<StoredUserDto> _users = [];
private readonly List<SessionDto> _sessions = [];
private readonly List<string> _calls = [];
/// <summary>
/// Подставка порта аутентификации (создаётся в конструкторе).
/// </summary>
public IAuthStore Store { get; }
/// <summary>
/// Пользователи хранилища.
/// </summary>
public IReadOnlyList<StoredUserDto> Users => _users;
/// <summary>
/// Сессии хранилища.
/// </summary>
public IReadOnlyList<SessionDto> Sessions => _sessions;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Создаёт подставку с пустыми списками.
/// </summary>
public TestAuthStore()
{
Store = Substitute.For<IAuthStore>();
Store.FindUserByLoginAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _users.SingleOrDefault(u => u.Login == ci.ArgAt<string>(0)));
Store.FindSessionByTokenHashAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _sessions.SingleOrDefault(s => s.TokenHash == ci.ArgAt<string>(0)));
Store.FindUserByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => FindById(ci.ArgAt<Guid>(0)));
Store.ListUsersByTenantIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(ci => ListByTenantId(ci.ArgAt<Guid>(0)));
Store.When(s => s.CreateUserAsync(Arg.Any<StoredUserDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
StoredUserDto user = ci.Arg<StoredUserDto>();
_users.Add(user);
_calls.Add($"create-user:{user.Id}");
});
Store.When(s => s.CreateSessionAsync(Arg.Any<SessionDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
SessionDto session = ci.Arg<SessionDto>();
_sessions.Add(session);
_calls.Add($"create-session:{session.UserId}");
});
Store.When(s => s.DeleteSessionAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
_sessions.RemoveAll(s => s.TokenHash == ci.ArgAt<string>(0));
_calls.Add($"delete-session:{ci.ArgAt<string>(0)}");
});
Store.When(s => s.DeleteSessionsByUserIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
_sessions.RemoveAll(s => s.UserId == ci.ArgAt<Guid>(0));
_calls.Add($"delete-user-sessions:{ci.ArgAt<Guid>(0)}");
});
Store.When(s => s.UpdatePasswordHashAsync(
Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdatePasswordHash(ci.ArgAt<Guid>(0), ci.ArgAt<string>(1)));
Store.When(s => s.DeleteExpiredSessionsAsync(Arg.Any<CancellationToken>()))
.Do(_ =>
{
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
_calls.Add("delete-expired");
});
}
/// <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);
private UserIdentityDto? FindById(Guid userId)
{
StoredUserDto? user = _users.SingleOrDefault(u => u.Id == userId);
return user is null ? null : new UserIdentityDto(user.Id, user.Login, user.TenantId, user.Status);
}
private IReadOnlyList<UserIdentityDto> ListByTenantId(Guid tenantId)
{
return _users
.Where(u => u.TenantId == tenantId)
.Select(u => new UserIdentityDto(u.Id, u.Login, u.TenantId, u.Status))
.ToList();
}
private void UpdatePasswordHash(Guid userId, string passwordHash)
{
int index = _users.FindIndex(u => u.Id == userId);
if (index >= 0)
{
StoredUserDto user = _users[index];
_users[index] = user with { PasswordHash = passwordHash };
}
_calls.Add($"update-password-hash:{userId}");
}
}
@@ -2,120 +2,262 @@ using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Modules.Cards;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// In-memory реализация <see cref="ICardStore"/> для unit-тестов сервисов единого домена карточки.
/// Подставка <see cref="ICardStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют состояние через <see cref="SeedBoard"/>,
/// <see cref="SeedCard"/>, <see cref="AddCard"/>, <see cref="Boards"/>, <see cref="CardDtos"/>,
/// <see cref="Moves"/>, <see cref="SearchCalls"/>. Сбой записи задаётся <see cref="FailAddCard"/>.
/// </summary>
public class FakeKanjStore : ICardStore
public sealed class TestKanjStore
{
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);
private readonly bool _throwOnDueReminders;
/// <summary>
/// Доски фейка (копия на момент обращения) — проверки BoardsServiceTests и LocalColumnSuggesterTests.
/// Подставка порта единого домена карточки (создаётся в конструкторе).
/// </summary>
public ICardStore Store { get; }
/// <summary>
/// Доски хранилища (копия на момент обращения).
/// </summary>
public IReadOnlyList<ContainerDto> Boards => _boards.ToList();
/// <summary>
/// Карточки фейка как тройки
/// Карточки хранилища как тройки (id, колонка, флаг «новая»).
/// </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
/// Записи журнала CardMoves.
/// </summary>
public IReadOnlyList<CardMoveDto> Moves => _moves.ToList();
/// <summary>
/// Вызовы SearchCardsAsync как пары
/// Вызовы SearchCardsAsync как пары (запрос, лимит).
/// </summary>
public IReadOnlyList<(string Query, int Limit)> SearchCalls => _searchCalls.ToList();
/// <summary>
/// Создаёт подставку с пустыми списками.
/// </summary>
/// <param name="throwOnDueReminders">Сценарий сбоя выборки due-напоминаний: ListDueRemindersAsync бросает.</param>
public TestKanjStore(bool throwOnDueReminders = false)
{
_throwOnDueReminders = throwOnDueReminders;
Store = Substitute.For<ICardStore>();
ConfigureContainers();
ConfigureCards();
ConfigureComments();
ConfigureMoves();
ConfigureStorage();
ConfigureSelected();
ConfigureReminders();
}
/// <summary>
/// Кладёт доску напрямую
/// </summary>
/// <param name="board">Доска как если бы была сохранена в БД.</param>
public void SeedBoard(ContainerDto board)
{
_boards.Add(board);
}
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 });
}
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)
public void SeedCard(CardDto card) => _cards.Add(card);
/// <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) =>
_archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null;
private void ConfigureContainers()
{
_cards.Add(card);
Store.ListContainersAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(ci => ListContainers(ci.ArgAt<string?>(0)));
Store.GetContainerAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _boards.FirstOrDefault(board => board.Id == ci.ArgAt<string>(0)));
Store.When(s => s.CreateContainerAsync(Arg.Any<ContainerDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _boards.Add(ci.Arg<ContainerDto>()));
Store.When(s => s.UpdateContainerAsync(Arg.Any<ContainerDto>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdateContainer(ci.Arg<ContainerDto>()));
Store.DeleteContainerAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => DeleteContainer(ci.ArgAt<string>(0)));
Store.When(s => s.ReorderContainersAsync(
Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>()))
.Do(ci => ReorderContainers(ci.ArgAt<string>(0), ci.ArgAt<IReadOnlyList<string>>(1)));
}
// ── Контейнеры (колонки/стадии/зоны) ─────────────────────────────────
/// <inheritdoc />
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct)
private void ConfigureCards()
{
IReadOnlyList<ContainerDto> ordered = _boards
Store.ListCardsAsync(Arg.Any<CardsQuery>(), Arg.Any<CancellationToken>())
.Returns(ci => ListCards(ci.Arg<CardsQuery>()));
Store.SearchCardsAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => SearchCards(ci.ArgAt<string>(0), ci.ArgAt<int>(1)));
Store.GetCardAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _cards.FirstOrDefault(card => card.Id == ci.ArgAt<string>(0)));
Store.GetCardBySourceAsync(Arg.Any<SourceRef>(), Arg.Any<CancellationToken>())
.Returns(ci => GetCardBySource(ci.Arg<SourceRef>()));
Store.When(s => s.AddCardAsync(Arg.Any<CardSnapshot>(), Arg.Any<CancellationToken>()))
.Do(ci => AddCard(ci.Arg<CardSnapshot>()));
Store.When(s => s.UpdateColumnAsync(Arg.Any<CardColumnUpdateDto>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdateColumn(ci.Arg<CardColumnUpdateDto>()));
Store.ApplyReclassificationAsync(Arg.Any<CardReclassificationDto>(), Arg.Any<CancellationToken>())
.Returns(ci => ApplyReclassification(ci.Arg<CardReclassificationDto>()));
Store.When(s => s.UpdateSeenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdateSeen(ci.ArgAt<string?>(0), ci.ArgAt<string?>(1)));
Store.When(s => s.DeleteForeverAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => DeleteForever(ci.ArgAt<string>(0)));
Store.ClearColAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => ClearCol(ci.ArgAt<string>(0)));
Store.CountCardsByColAsync(Arg.Any<CancellationToken>())
.Returns(_ => CountCardsByCol());
}
private void ConfigureComments()
{
Store.ListCommentsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _cards.FirstOrDefault(card => card.Id == ci.ArgAt<string>(0))?.Comments
?? Array.Empty<CardCommentDto>());
Store.When(s => s.AddCommentAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => AddComment(
ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<string>(3)));
}
private void ConfigureMoves()
{
Store.When(s => s.AddMoveAsync(Arg.Any<CardMoveDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _moves.Add(ci.Arg<CardMoveDto>()));
Store.CountMovesAsync(Arg.Any<CancellationToken>()).Returns(_ => _moves.Count);
Store.GetAiMarkupExamplesAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => GetAiMarkupExamples(ci.ArgAt<int>(0)));
}
private void ConfigureStorage()
{
Store.ListArchiveCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ListArchiveCandidates(ci.ArgAt<DateTimeOffset>(0)));
Store.ArchiveAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => Archive(ci.ArgAt<IReadOnlyList<string>>(0), ci.ArgAt<DateTimeOffset>(1)));
Store.ListExpiredArchiveCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ListExpiredArchiveCandidates(ci.ArgAt<DateTimeOffset>(0)));
Store.ListTrashCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ListTrashCandidates(ci.ArgAt<DateTimeOffset>(0)));
Store.PurgeAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(ci => Purge(ci.ArgAt<IReadOnlyList<string>>(0)));
Store.ListCardsForConversionAsync(Arg.Any<CancellationToken>())
.Returns(_ => ListCardsForConversion());
Store.When(s => s.UpdateConversionAsync(
Arg.Any<string>(), Arg.Any<double?>(), Arg.Any<double?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdateConversion(
ci.ArgAt<string>(0), ci.ArgAt<double?>(1), ci.ArgAt<double?>(2), ci.ArgAt<string>(3)));
}
private void ConfigureSelected()
{
Store.ListSelectedCardsAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(ci => ListSelectedCards(ci.ArgAt<string?>(0)));
Store.PatchCardAsync(Arg.Any<string>(), Arg.Any<CardPatch>(), Arg.Any<CancellationToken>())
.Returns(ci => PatchCard(ci.ArgAt<string>(0), ci.Arg<CardPatch>()));
Store.AddLinkAsync(Arg.Any<string>(), Arg.Any<CardLinkDto>(), Arg.Any<CancellationToken>())
.Returns(ci => AddLink(ci.ArgAt<string>(0), ci.Arg<CardLinkDto>()));
Store.RemoveLinkAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => RemoveLink(ci.ArgAt<string>(0), ci.ArgAt<string>(1)));
Store.AddFileAsync(Arg.Any<string>(), Arg.Any<CardFileDto>(), Arg.Any<CancellationToken>())
.Returns(ci => AddFile(ci.ArgAt<string>(0), ci.Arg<CardFileDto>()));
Store.RemoveFileAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => RemoveFile(ci.ArgAt<string>(0), ci.ArgAt<string>(1)));
Store.MoveCardStageAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CardHistoryDto>(), Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns(ci => MoveCardStage(
ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.Arg<CardHistoryDto>(), ci.ArgAt<long>(3)));
}
private void ConfigureReminders()
{
Store.When(s => s.SetReminderAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<CancellationToken>()))
.Do(ci => SetReminder(ci.ArgAt<string>(0), ci.ArgAt<long>(1)));
Store.When(s => s.ClearReminderAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => ClearReminder(ci.ArgAt<string>(0)));
Store.ClearStageAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => ClearStage(ci.ArgAt<string>(0)));
if (_throwOnDueReminders)
{
Store.ListDueRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<CardReminderDueDto>>>(_ =>
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."));
}
else
{
Store.ListDueRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ListDueReminders(ci.ArgAt<DateTimeOffset>(0)));
}
Store.When(s => s.MarkRemindersFiredAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>()))
.Do(ci => MarkRemindersFired(ci.ArgAt<IReadOnlyList<string>>(0)));
Store.ClearExpiredRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ClearExpiredReminders(ci.ArgAt<DateTimeOffset>(0)));
Store.ListInboxWithSourceAsync(Arg.Any<CancellationToken>())
.Returns(_ => ListInboxWithSource());
}
private IReadOnlyList<ContainerDto> ListContainers(string? space)
{
return _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)
private void UpdateContainer(ContainerDto container)
{
int index = _boards.FindIndex(item => item.Id == container.Id);
_boards[index] = container;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct)
private int DeleteContainer(string containerId)
{
List<CardDto> moved = _cards.Where(card => card.Col == containerId).ToList();
int moved = _cards.Count(card => card.Col == containerId);
for (int i = 0; i < _cards.Count; i++)
{
if (_cards[i].Col == containerId)
@@ -125,54 +267,39 @@ public class FakeKanjStore : ICardStore
}
_boards.RemoveAll(board => board.Id == containerId);
return Task.FromResult(moved.Count);
return moved;
}
/// <inheritdoc />
public Task ReorderContainersAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct)
private void ReorderContainers(string space, IReadOnlyList<string> containerIds)
{
for (int i = 0; i < containerIds.Count; i++)
{
string containerId = containerIds[i];
int index = _boards.FindIndex(board => board.Id == containerId && board.Space == space);
int index = _boards.FindIndex(board => board.Id == containerIds[i] && 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)
private IReadOnlyList<CardDto> ListCards(CardsQuery query)
{
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());
return result.OrderByDescending(card => card.ReceivedAtMs).ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(
string q,
int limit,
CancellationToken ct)
private IReadOnlyList<CardDto> SearchCards(string q, int limit)
{
_searchCalls.Add((q, limit));
string lowered = q.Trim().ToLowerInvariant();
if (lowered.Length == 0)
{
return Task.FromResult<IReadOnlyList<CardDto>>(Array.Empty<CardDto>());
return Array.Empty<CardDto>();
}
IReadOnlyList<CardDto> result = _cards
return _cards
.Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
@@ -180,38 +307,26 @@ public class FakeKanjStore : ICardStore
.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)
private CardDto? GetCardBySource(SourceRef source)
{
if (string.IsNullOrEmpty(source.Kind))
{
return Task.FromResult<CardDto?>(null);
return null;
}
string externalId = source.ExternalId ?? string.Empty;
string originRef = source.OriginRef ?? string.Empty;
CardDto? match = _cards
return _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)
private void AddCard(CardSnapshot snapshot)
{
if (FailAddCard)
{
@@ -249,16 +364,14 @@ public class FakeKanjStore : ICardStore
CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
private void UpdateColumn(CardColumnUpdateDto update)
{
int index = _cards.FindIndex(card => card.Id == update.CardId);
if (index < 0)
{
return Task.CompletedTask;
return;
}
_cards[index] = _cards[index] with
@@ -276,17 +389,14 @@ public class FakeKanjStore : ICardStore
{
_archivedAtById.Remove(update.CardId);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
private bool ApplyReclassification(CardReclassificationDto update)
{
int index = _cards.FindIndex(card => card.Id == update.CardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -304,40 +414,28 @@ public class FakeKanjStore : ICardStore
Contacts = update.Contacts,
MatchHits = update.MatchHits,
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task UpdateSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
private void UpdateSeen(string? cardId, string? col)
{
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;
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)
private void DeleteForever(string cardId)
{
// Удаляется карточка (с комментариями — они часть карточки в фейке); журнал CardMoves не трогаем.
_cards.RemoveAll(card => card.Id == cardId);
_archivedAtById.Remove(cardId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearColAsync(string col, CancellationToken ct)
private int ClearCol(string col)
{
List<string> removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList();
_cards.RemoveAll(card => card.Col == col);
@@ -346,64 +444,33 @@ public class FakeKanjStore : ICardStore
_archivedAtById.Remove(cardId);
}
return Task.FromResult(removedIds.Count);
return removedIds.Count;
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
private IReadOnlyDictionary<string, CardColumnCountDto> CountCardsByCol()
{
IReadOnlyDictionary<string, CardColumnCountDto> result = _cards
return _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)
private void AddComment(string commentId, string cardId, string by, string text)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.CompletedTask;
return;
}
// Метка времени свежего комментария — «только что» (адаптер считает 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)
private IReadOnlyList<AiMarkupExampleDto> GetAiMarkupExamples(int limit)
{
var examples = new List<AiMarkupExampleDto>();
for (int index = _moves.Count - 1; index >= 0 && examples.Count < limit; index--)
@@ -429,31 +496,24 @@ public class FakeKanjStore : ICardStore
examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol));
}
return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples);
return examples;
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
private IReadOnlyList<string> ListArchiveCandidates(DateTimeOffset receivedBeforeUtc)
{
var boardIds = new HashSet<string>(_boards.Select(board => board.Id), StringComparer.Ordinal);
IReadOnlyList<string> result = _cards
return _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)
private int Archive(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt)
{
if (cardIds.Count == 0)
{
return Task.FromResult(0);
return 0;
}
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
@@ -475,38 +535,32 @@ public class FakeKanjStore : ICardStore
archived++;
}
return Task.FromResult(archived);
return archived;
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
private IReadOnlyList<string> ListExpiredArchiveCandidates(DateTimeOffset archivedBeforeUtc)
{
IReadOnlyList<string> result = _cards
return _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)
private IReadOnlyList<string> ListTrashCandidates(DateTimeOffset receivedBeforeUtc)
{
IReadOnlyList<string> result = _cards
return _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)
private int Purge(IReadOnlyList<string> cardIds)
{
// Жёсткое удаление пачки (как KanbanStore.PurgeAsync): комментарии — часть карточки, журнал не трогаем.
if (cardIds.Count == 0)
{
return Task.FromResult(0);
return 0;
}
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
@@ -516,88 +570,45 @@ public class FakeKanjStore : ICardStore
_archivedAtById.Remove(cardId);
}
return Task.FromResult(removed);
return 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)
private IReadOnlyList<CardDto> ListCardsForConversion()
{
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
return _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)
private void UpdateConversion(string cardId, double? convFrom, double? convTo, string convCur)
{
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),
Converted = convCur.Length == 0 ? null : new CardBudgetDto(convFrom, convTo, convCur),
};
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
private IReadOnlyList<CardDto> ListSelectedCards(string? containerId)
{
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());
return query.OrderByDescending(card => card.UpdatedAtMs).ToList();
}
/// <inheritdoc />
public Task<bool> PatchCardAsync(
string cardId,
CardPatch patch,
CancellationToken ct)
private bool PatchCard(string cardId, CardPatch patch)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
CardDto card = _cards[index];
@@ -647,19 +658,15 @@ public class FakeKanjStore : ICardStore
}
_cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> AddLinkAsync(
string cardId,
CardLinkDto link,
CancellationToken ct)
private bool AddLink(string cardId, CardLinkDto link)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -667,19 +674,15 @@ public class FakeKanjStore : ICardStore
Links = [.. _cards[index].Links, link],
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
private bool RemoveLink(string cardId, string linkId)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -687,19 +690,15 @@ public class FakeKanjStore : ICardStore
Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> AddFileAsync(
string cardId,
CardFileDto file,
CancellationToken ct)
private bool AddFile(string cardId, CardFileDto file)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -707,19 +706,15 @@ public class FakeKanjStore : ICardStore
Files = [.. _cards[index].Files, file],
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
private bool RemoveFile(string cardId, string fileId)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -727,21 +722,15 @@ public class FakeKanjStore : ICardStore
Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> MoveCardStageAsync(
string cardId,
string containerId,
CardHistoryDto historyEntry,
long atMs,
CancellationToken ct)
private bool MoveCardStage(string cardId, string containerId, CardHistoryDto historyEntry, long atMs)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
CardDto card = _cards[index];
@@ -753,19 +742,15 @@ public class FakeKanjStore : ICardStore
UpdatedAtMs = atMs,
History = [.. card.History, historyEntry],
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
private void SetReminder(string cardId, long atMs)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.CompletedTask;
return;
}
_firedById.Remove(cardId);
@@ -774,11 +759,9 @@ public class FakeKanjStore : ICardStore
Reminder = new CardReminderDto(atMs),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ClearReminderAsync(string cardId, CancellationToken ct)
private void ClearReminder(string cardId)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index >= 0)
@@ -787,20 +770,14 @@ public class FakeKanjStore : ICardStore
}
_firedById.Remove(cardId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearStageAsync(string containerId, CancellationToken ct)
{
return Task.FromResult(_cards.RemoveAll(card => card.Col == containerId));
}
private int ClearStage(string containerId) => _cards.RemoveAll(card => card.Col == containerId);
/// <inheritdoc />
public virtual Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
private IReadOnlyList<CardReminderDueDto> ListDueReminders(DateTimeOffset now)
{
long nowMs = now.ToUnixTimeMilliseconds();
IReadOnlyList<CardReminderDueDto> due = _cards
return _cards
.Where(card => card.Col == CardsDefaultContainers.Hold
&& card.Reminder is not null
&& !_firedById.Contains(card.Id)
@@ -808,11 +785,9 @@ public class FakeKanjStore : ICardStore
.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)
private void MarkRemindersFired(IReadOnlyList<string> cardIds)
{
foreach (string cardId in cardIds)
{
@@ -821,12 +796,9 @@ public class FakeKanjStore : ICardStore
_firedById.Add(cardId);
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
private int ClearExpiredReminders(DateTimeOffset now)
{
long nowMs = now.ToUnixTimeMilliseconds();
int cleared = 0;
@@ -840,16 +812,16 @@ public class FakeKanjStore : ICardStore
}
}
return Task.FromResult(cleared);
return cleared;
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
private IReadOnlyList<CardDto> ListInboxWithSource()
{
IReadOnlyList<CardDto> result = _cards
return _cards
.Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty)
.OrderByDescending(card => card.ReceivedAtMs)
.ToList();
return Task.FromResult(result);
}
private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs);
}
@@ -1,15 +1,22 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Contracts;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// In-memory реализация <see cref="IMlClient"/> для unit-тестов CardsService и воркера pump.
/// Подставка <see cref="IMlClient"/>: сервисы получают NSubstitute-подставку (<see cref="Client"/>),
/// тесты управляют сценариями через свойства (как раньше — через фейк).
/// </summary>
public sealed class FakeMlClient : IMlClient
public sealed class TestMlClient
{
private readonly List<(string Text, string Label, double Delta)> _pushed = [];
/// <summary>
/// Подставка порта ML-клиента (создаётся в конструкторе).
/// </summary>
public IMlClient Client { get; }
/// <summary>
/// Ответ StatusAsync
/// </summary>
@@ -26,37 +33,30 @@ public sealed class FakeMlClient : IMlClient
public int PredictCalls { get; private set; }
/// <summary>
/// Обучающие сигналы, отправленные через <see cref="PushAsync"/>
/// Обучающие сигналы, отправленные через <see cref="IMlClient.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)
/// <summary>
/// Создаёт подставку с поведением по умолчанию.
/// </summary>
public TestMlClient()
{
_pushed.Add((text, label, delta));
return Task.CompletedTask;
Client = Substitute.For<IMlClient>();
Client.StatusAsync(Arg.Any<CancellationToken>())
.Returns(ci => Status);
Client.When(c => c.PushAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<double>(), Arg.Any<CancellationToken>()))
.Do(ci => _pushed.Add((ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<double>(2))));
Client.PredictAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<MlPredictResultDto>(ci =>
{
PredictCalls++;
return Predict ?? throw new NotSupportedException();
});
Client.ResetAsync(Arg.Any<CancellationToken>())
.Returns<MlResetResultDto>(_ => throw new NotSupportedException());
}
/// <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()
@@ -0,0 +1,65 @@
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IMlLearningStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют очередь через <see cref="SeedOutbox"/> и <see cref="AddedRows"/>.
/// </summary>
public sealed class TestMlLearningStore
{
private readonly List<(string Id, string Text, string Label, double Delta)> _rows = [];
/// <summary>
/// Подставка порта ML-обучения (создаётся в конструкторе).
/// </summary>
public IMlLearningStore Store { get; }
/// <summary>
/// Число записей журнала обучения
/// </summary>
public int LearningCount { get; set; }
/// <summary>
/// Строки очереди
/// </summary>
public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList();
/// <summary>
/// Создаёт подставку с пустой очередью.
/// </summary>
public TestMlLearningStore()
{
Store = Substitute.For<IMlLearningStore>();
Store.CountLearningAsync(Arg.Any<CancellationToken>())
.Returns(ci => LearningCount);
Store.CountOutboxAsync(Arg.Any<CancellationToken>())
.Returns(_ => _rows.Count);
Store.When(s => s.AddOutboxAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<double>(), Arg.Any<CancellationToken>()))
.Do(ci => _rows.Add((ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<double>(3))));
Store.When(s => s.ClearOutboxAsync(Arg.Any<CancellationToken>()))
.Do(_ => _rows.Clear());
Store.TakeOutboxBatchAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => _rows
.Take(ci.ArgAt<int>(0))
.Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta))
.ToList());
Store.When(s => s.DeleteOutboxAsync(Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>()))
.Do(ci => _rows.RemoveAll(row => ci.Arg<IReadOnlyCollection<string>>().Contains(row.Id)));
}
/// <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));
}
}
@@ -0,0 +1,88 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IOperatorAuthStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют операторов и сессии через
/// <see cref="AddOperator"/>, <see cref="AddSession"/>, <see cref="Operators"/>, <see cref="Sessions"/>,
/// журнал мутирующих вызовов — <see cref="Calls"/>.
/// </summary>
public sealed class TestOperatorAuthStore
{
private readonly List<StoredOperatorDto> _operators = [];
private readonly List<OperatorSessionDto> _sessions = [];
private readonly List<string> _calls = [];
/// <summary>
/// Подставка порта операторской аутентификации (создаётся в конструкторе).
/// </summary>
public IOperatorAuthStore Store { get; }
/// <summary>
/// Операторы хранилища.
/// </summary>
public IReadOnlyList<StoredOperatorDto> Operators => _operators;
/// <summary>
/// Сессии операторов хранилища.
/// </summary>
public IReadOnlyList<OperatorSessionDto> Sessions => _sessions;
/// <summary>
/// Журнал мутирующих вызовов в порядке их совершения.
/// </summary>
public IReadOnlyList<string> Calls => _calls;
/// <summary>
/// Создаёт подставку с пустыми списками.
/// </summary>
public TestOperatorAuthStore()
{
Store = Substitute.For<IOperatorAuthStore>();
Store.FindByLoginAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _operators.SingleOrDefault(o => o.Login == ci.ArgAt<string>(0)));
Store.FindSessionByTokenHashAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _sessions.SingleOrDefault(s => s.TokenHash == ci.ArgAt<string>(0)));
Store.When(s => s.CreateAsync(Arg.Any<StoredOperatorDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
StoredOperatorDto operatorRecord = ci.Arg<StoredOperatorDto>();
_operators.Add(operatorRecord);
_calls.Add($"create-operator:{operatorRecord.Id}");
});
Store.When(s => s.CreateSessionAsync(Arg.Any<OperatorSessionDto>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
OperatorSessionDto session = ci.Arg<OperatorSessionDto>();
_sessions.Add(session);
_calls.Add($"create-session:{session.OperatorId}");
});
Store.When(s => s.DeleteSessionAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
_sessions.RemoveAll(s => s.TokenHash == ci.ArgAt<string>(0));
_calls.Add($"delete-session:{ci.ArgAt<string>(0)}");
});
Store.When(s => s.DeleteExpiredSessionsAsync(Arg.Any<CancellationToken>()))
.Do(_ =>
{
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
_calls.Add("delete-expired");
});
}
/// <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);
}
@@ -0,0 +1,293 @@
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Abstractions;
using Deal.Modules.Pipeline.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IPipelineStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют очередь и отсеянных через <see cref="Queue"/>,
/// <see cref="Rejected"/>, <see cref="SeedQueue"/>, <see cref="SeedRejected"/>.
/// </summary>
public sealed class TestPipelineStore
{
private readonly List<QueueItemDto> _queue = [];
private readonly List<RejectedItemDto> _rejected = [];
private readonly Dictionary<string, string?> _dedup = new(StringComparer.Ordinal);
/// <summary>
/// Подставка порта пайплайна (создаётся в конструкторе).
/// </summary>
public IPipelineStore Store { get; }
/// <summary>
/// Строки очереди подставки
/// </summary>
public IReadOnlyList<QueueItemDto> Queue => _queue.ToList();
/// <summary>
/// Записи отсева подставки
/// </summary>
public IReadOnlyList<RejectedItemDto> Rejected => _rejected.ToList();
private readonly bool _claimRace;
private readonly bool _claimResult;
private readonly bool _throwOnList;
/// <summary>
/// Сколько раз вызван ClaimAsync
/// </summary>
public int ClaimCalls { get; private set; }
/// <summary>
/// Сколько раз вызван DeleteClaimAsync
/// </summary>
public int DeleteClaimCalls { get; private set; }
/// <summary>
/// Создаёт подставку с пустыми списками.
/// </summary>
/// <param name="claimRace">Сценарий claim-гонки: ExistsAsync=false, ClaimAsync возвращает claimResult.</param>
/// <param name="claimResult">Результат ClaimAsync в сценарии гонки (false — хэш уже заявлен другим проходом).</param>
/// <param name="throwOnList">Сценарий сбоя чтения очереди: ListAsync бросает (имитация недоступной схемы/БД).</param>
public TestPipelineStore(
bool claimRace = false,
bool claimResult = false,
bool throwOnList = false)
{
_claimRace = claimRace;
_claimResult = claimResult;
_throwOnList = throwOnList;
Store = Substitute.For<IPipelineStore>();
Store.ExistsDuplicateAsync(Arg.Any<SourceRef>(), Arg.Any<CancellationToken>())
.Returns(ci => ExistsDuplicate(ci.Arg<SourceRef>()));
Store.When(s => s.AddAsync(Arg.Any<QueueItemDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _queue.Add(ci.Arg<QueueItemDto>()));
if (_throwOnList)
{
Store.ListAsync(Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<QueueItemDto>>>(_ =>
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."));
}
else
{
Store.ListAsync(Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => List(ci.ArgAt<string?>(0), ci.ArgAt<int>(1)));
}
Store.CountByStatusAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => CountByStatus(ci.ArgAt<string>(0)));
Store.When(s => s.SetStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => SetStatus(ci.ArgAt<string>(0), ci.ArgAt<string>(1)));
Store.When(s => s.RemoveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => Remove(ci.ArgAt<string>(0)));
Store.When(s => s.UpsertAsync(Arg.Any<RejectRecord>(), Arg.Any<CancellationToken>()))
.Do(ci => Upsert(ci.Arg<RejectRecord>()));
Store.ListPageAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => ListPage(ci.ArgAt<int>(0), ci.ArgAt<int>(1)));
Store.SearchAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => Search(ci.ArgAt<string>(0), ci.ArgAt<int>(2)));
Store.CountAsync(Arg.Any<CancellationToken>()).Returns(_ => _rejected.Count);
Store.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _rejected.FirstOrDefault(item => item.Id == ci.ArgAt<string>(0)));
Store.When(s => s.DeleteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => _rejected.RemoveAll(item => item.Id == ci.ArgAt<string>(0)));
Store.ClearAsync(Arg.Any<CancellationToken>())
.Returns(ci =>
{
int cleared = _rejected.Count;
_rejected.Clear();
return cleared;
});
Store.PurgeExpiredAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => PurgeExpired(ci.ArgAt<DateTimeOffset>(0)));
Store.When(s => s.MarkReturnedAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()))
.Do(ci => MarkReturned(ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<DateTimeOffset>(2)));
Store.When(s => s.LinkAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => _dedup[ci.ArgAt<string>(0)] = ci.ArgAt<string>(1));
Store.When(s => s.DeleteByCardAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => DeleteByCard(ci.ArgAt<string>(0)));
Store.ExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => !_claimRace && _dedup.ContainsKey(ci.ArgAt<string>(0)));
Store.When(s => s.ClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(_ => ClaimCalls++);
Store.ClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _claimRace
? _claimResult
: _dedup.TryAdd(ci.ArgAt<string>(0), null));
Store.When(s => s.DeleteClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(_ => DeleteClaimCalls++);
Store.When(s => s.DeleteClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
if (!_claimRace && _dedup.TryGetValue(ci.ArgAt<string>(0), out string? leadId) && leadId is null)
{
_dedup.Remove(ci.ArgAt<string>(0));
}
});
}
/// <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);
}
/// <summary>
/// Id карточки, связанной с хэшем дедупа
/// </summary>
/// <param name="hash">SHA1-hex нормализованного текста.</param>
public string? DedupLeadId(string hash)
{
return _dedup.TryGetValue(hash, out string? leadId) ? leadId : null;
}
private bool ExistsDuplicate(SourceRef source)
{
string key = source.DedupeKey();
return _queue.Any(item => item.Source.DedupeKey() == key);
}
private IReadOnlyList<QueueItemDto> List(string? status, int limit)
{
return _queue
.Where(item => status is null || item.Status == status)
.OrderBy(item => item.QueuedAtMs)
.Take(limit)
.ToList();
}
private int CountByStatus(string status) => _queue.Count(item => item.Status == status);
private void SetStatus(string id, string status)
{
int index = _queue.FindIndex(item => item.Id == id);
if (index >= 0)
{
_queue[index] = _queue[index] with { Status = status };
}
}
private void Remove(string id) => _queue.RemoveAll(item => item.Id == id);
private void Upsert(RejectRecord record)
{
if (string.IsNullOrWhiteSpace(record.Text))
{
return;
}
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);
}
}
private IReadOnlyList<RejectedItemDto> ListPage(int offset, int limit)
{
return _rejected
.OrderByDescending(item => item.RejectedAtMs)
.Skip(offset)
.Take(limit)
.ToList();
}
private IReadOnlyList<RejectedItemDto> Search(string q, int limitLike)
{
string query = q.Trim();
return _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();
}
private int PurgeExpired(DateTimeOffset olderThan)
{
long cutMs = olderThan.ToUnixTimeMilliseconds();
return _rejected.RemoveAll(item => item.RejectedAtMs < cutMs);
}
private void MarkReturned(string id, string reason, DateTimeOffset returnedAt)
{
int index = _rejected.FindIndex(item => item.Id == id);
if (index >= 0)
{
_rejected[index] = _rejected[index] with
{
Returned = true,
ReturnedAtMs = returnedAt.ToUnixTimeMilliseconds(),
ReturnReason = reason,
};
}
}
private void DeleteByCard(string cardId)
{
foreach (string hash in _dedup.Where(pair => pair.Value == cardId).Select(pair => pair.Key).ToList())
{
_dedup.Remove(hash);
}
}
// Подстрока 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,62 @@
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="ISettingsStore"/> на словаре: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют настройки через <see cref="Preload"/>,
/// <see cref="GetStoredJson"/> и <see cref="Keys"/>.
/// </summary>
public sealed class TestSettingsStore
{
private readonly Dictionary<string, SettingValue> _rows = new(StringComparer.Ordinal);
/// <summary>
/// Подставка порта настроек (создаётся в конструкторе).
/// </summary>
public ISettingsStore Store { get; }
/// <summary>
/// Ключи сохранённых настроек.
/// </summary>
public IReadOnlyCollection<string> Keys => _rows.Keys.ToList();
/// <summary>
/// Создаёт подставку с пустым словарём.
/// </summary>
public TestSettingsStore()
{
Store = Substitute.For<ISettingsStore>();
Store.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _rows.TryGetValue(ci.ArgAt<string>(0), out SettingValue? row) ? row : null);
Store.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(ci => _rows.Values.ToList());
Store.When(s => s.SetAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => _rows[ci.ArgAt<string>(0)] =
new SettingValue(ci.ArgAt<string>(0), ci.ArgAt<string>(1), DateTimeOffset.UtcNow));
Store.When(s => s.RemoveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => _rows.Remove(ci.ArgAt<string>(0)));
}
/// <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;
}
}
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Support;
/// </summary>
public sealed class TokenUsageRecorderTests
{
// Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для FakeTenantLimitStore.
// Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для TestTenantLimitStore.
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
@@ -26,7 +26,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent()
{
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "deepseek-chat", CancellationToken.None);
@@ -52,7 +52,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
public async Task AddAsync_AccumulatesAcrossCalls()
{
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None);
await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "deepseek", "m", CancellationToken.None);
@@ -68,7 +68,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
public async Task AddAsync_NullUsage_IsNoop()
{
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None);
@@ -80,7 +80,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits()
{
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
// Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
@@ -96,11 +96,11 @@ public sealed class TokenUsageRecorderTests
[Fact]
public async Task AddAsync_OutsideTenantContext_Throws()
{
var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore();
var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
var recorder = new TokenUsageRecorder(
settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store));
settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store));
await Assert.ThrowsAsync<InvalidOperationException>(
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
@@ -111,7 +111,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate()
{
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
// 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются.
long estimated = await recorder.AddEstimatedAsync(
@@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
// Возвращает: Кортеж (настройки, лимиты, история, recorder).
private static (FakeSettingsStore Settings, FakeTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder()
private static (TestSettingsStore Settings, TestTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder()
{
var settings = new FakeSettingsStore();
var limits = new FakeTenantLimitStore();
var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore();
var events = new TestTokenUsageEventStore();
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var recorder = new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(events.Store));
var recorder = new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(events.Store));
return (settings, limits, events, recorder);
}
}